Skip to main content

browser_automation_cli/
concurrency.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Bounded parallelism and concurrency for the one-shot CLI.
3//!
4//! # Workload classification (rules_rust_paralelismo)
5//!
6//! | Class | Paths | Tool |
7//! |-------|-------|------|
8//! | **I/O-bound** | HTTP scrape/crawl/batch, CDP fan-out, robots | Tokio + `Arc<Semaphore>` / `JoinSet` / [`join_bounded`] |
9//! | **CPU-bound** | Structural scan (`sg`), multi-file text match | Rayon `par_iter` |
10//! | **Mista** | Browser session (CDP I/O + light DOM parse) | Multi-thread Tokio; no Rayon on async workers |
11//! | **Subprocess** | Chrome residual | Existing residual kill path (no unbounded fork) |
12//!
13//! # Formula (permits)
14//!
15//! ```text
16//! auto = min(
17//!   available_parallelism(),
18//!   max(1, (free_ram_mb * 50%) / ram_per_task_mb),
19//!   HARD_CAP
20//! )
21//! effective = override_if_set_else(auto)
22//! ```
23//!
24//! - **ram_per_task_mb:** 64 for HTTP I/O tasks (conservative floor).
25//!   Measure with `/usr/bin/time -v` → "Maximum resident set size" via
26//!   `scripts/rss-baseline.sh` (doctor offline / single scrape). Revalidate when
27//!   reqwest/scraper/chromiumoxide jump materially.
28//! - **50% safety margin** on free RAM so concurrent tasks cannot OOM the host.
29//! - **HARD_CAP** prevents FD / Chrome overwhelm on large hosts.
30//! - Override: global CLI `--max-concurrency=N` (`0` = auto).
31//! - **One-shot:** auto budget is cached once (`OnceLock`); long daemon rebalance
32//!   is N/A (BORN→EXECUTE→FINALIZE→DIE). Crawl/batch respect cancel token mid-run.
33//!
34//! # Product law
35//!
36//! One-shot CLI (BORN→EXECUTE→FINALIZE→DIE). No daemon fleets, no remote
37//! metrics of `available_permits`, no `systemd-run` default (ops N/A).
38//! Every fan-out **must** use [`effective_limit`] or an explicit local clamp
39//! derived from it. Gate of record: `Arc<Semaphore>` + `acquire_owned`.
40
41use std::future::Future;
42use std::sync::atomic::{AtomicUsize, Ordering};
43use std::sync::{Arc, OnceLock};
44
45use futures_util::stream::{self, StreamExt};
46use tokio::sync::Semaphore;
47
48/// Hard ceiling for any fan-out (FD budget + Chrome CDP politeness).
49pub const HARD_CAP: usize = 64;
50
51/// Floor when auto-detection fails.
52pub const MIN_CONCURRENCY: usize = 1;
53
54/// Conservative RSS budget per concurrent HTTP/CDP task (MiB).
55///
56/// Ground-truth method: `/usr/bin/time -v` → "Maximum resident set size"
57/// (`scripts/rss-baseline.sh`). Value is a **rounded-up floor** so concurrent
58/// tasks leave headroom under the 50% free-RAM margin. Revalidate when
59/// reqwest/scraper or chromiumoxide versions jump materially.
60pub const RAM_PER_IO_TASK_MB: u64 = 64;
61
62/// Process-wide override from `--max-concurrency` (`0` = use auto).
63static OVERRIDE: AtomicUsize = AtomicUsize::new(0);
64
65/// Cached auto budget (computed once per process; one-shot CLI does not rebalance).
66static AUTO_BUDGET: OnceLock<usize> = OnceLock::new();
67
68/// Workload class for documentation and call-site comments.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum WorkloadClass {
71    /// Network / disk wait dominates (Tokio).
72    IoBound,
73    /// Pure CPU over in-memory or per-file data (Rayon).
74    CpuBound,
75    /// Mixed stages; isolate CPU with `spawn_blocking` / Rayon off async.
76    Mixed,
77    /// External process (Chrome); never unbounded spawn loops.
78    Subprocess,
79}
80
81/// Install CLI override. Call once after clap parse.
82///
83/// - `0` → auto (CPU × RAM formula)
84/// - `N>0` → clamp to `[MIN_CONCURRENCY, HARD_CAP]`
85pub fn install_limit(max_concurrency: usize) {
86    let v = if max_concurrency == 0 {
87        0
88    } else {
89        max_concurrency.clamp(MIN_CONCURRENCY, HARD_CAP)
90    };
91    OVERRIDE.store(v, Ordering::Relaxed);
92}
93
94/// Effective concurrency for I/O fan-out (and Rayon thread hint).
95pub fn effective_limit() -> usize {
96    let over = OVERRIDE.load(Ordering::Relaxed);
97    if over > 0 {
98        return over;
99    }
100    *AUTO_BUDGET.get_or_init(compute_auto_budget)
101}
102
103/// Same as [`effective_limit`] but capped for a specific subsystem.
104pub fn effective_limit_capped(cap: usize) -> usize {
105    effective_limit().min(cap.max(MIN_CONCURRENCY))
106}
107
108/// CPU count used in the formula (`available_parallelism`, min 1).
109pub fn cpu_count() -> usize {
110    std::thread::available_parallelism()
111        .map(|n| n.get())
112        .unwrap_or(MIN_CONCURRENCY)
113        .max(MIN_CONCURRENCY)
114}
115
116/// Free / available RAM in MiB when the platform exposes it.
117///
118/// - **Linux:** `MemAvailable` (preferred) or `MemFree` from `/proc/meminfo`.
119/// - **macOS:** free + inactive pages via `host_statistics64` (best-effort).
120/// - **Windows:** `ullAvailPhys` via `GlobalMemoryStatusEx`.
121/// - Other targets: `None` → formula falls back to CPU count only.
122pub fn free_ram_mb() -> Option<u64> {
123    #[cfg(target_os = "linux")]
124    {
125        let text = std::fs::read_to_string("/proc/meminfo").ok()?;
126        // Prefer MemAvailable (accounts for cache reclaim).
127        for line in text.lines() {
128            if let Some(rest) = line.strip_prefix("MemAvailable:") {
129                let kb: u64 = rest
130                    .split_whitespace()
131                    .next()
132                    .and_then(|s| s.parse().ok())?;
133                return Some(kb / 1024);
134            }
135        }
136        // Fallback MemFree.
137        for line in text.lines() {
138            if let Some(rest) = line.strip_prefix("MemFree:") {
139                let kb: u64 = rest
140                    .split_whitespace()
141                    .next()
142                    .and_then(|s| s.parse().ok())?;
143                return Some(kb / 1024);
144            }
145        }
146        None
147    }
148    #[cfg(target_os = "macos")]
149    {
150        free_ram_mb_macos()
151    }
152    #[cfg(windows)]
153    {
154        free_ram_mb_windows()
155    }
156    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
157    {
158        None
159    }
160}
161
162#[cfg(target_os = "macos")]
163fn free_ram_mb_macos() -> Option<u64> {
164    // host_statistics64(HOST_VM_INFO64): free + inactive pages ≈ reclaimable.
165    // SAFETY: zeroed vm_statistics64 is a valid out-buffer; host is host_self.
166    unsafe {
167        let mut count = libc::HOST_VM_INFO64_COUNT;
168        let mut stat: libc::vm_statistics64 = std::mem::zeroed();
169        let host = libc::mach_host_self();
170        let kr = libc::host_statistics64(
171            host,
172            libc::HOST_VM_INFO64,
173            &mut stat as *mut _ as *mut _,
174            &mut count,
175        );
176        if kr != libc::KERN_SUCCESS {
177            return None;
178        }
179        let page = libc::sysconf(libc::_SC_PAGESIZE);
180        if page <= 0 {
181            return None;
182        }
183        let pages = (stat.free_count as u64).saturating_add(stat.inactive_count as u64);
184        Some(pages.saturating_mul(page as u64) / (1024 * 1024))
185    }
186}
187
188#[cfg(windows)]
189fn free_ram_mb_windows() -> Option<u64> {
190    use windows_sys::Win32::System::SystemInformation::{
191        GlobalMemoryStatusEx, MEMORYSTATUSEX,
192    };
193    // SAFETY: dwLength set before call; structure fully written on success.
194    unsafe {
195        let mut st: MEMORYSTATUSEX = std::mem::zeroed();
196        st.dwLength = std::mem::size_of::<MEMORYSTATUSEX>() as u32;
197        if GlobalMemoryStatusEx(&mut st) == 0 {
198            return None;
199        }
200        Some(st.ullAvailPhys / (1024 * 1024))
201    }
202}
203
204/// Auto budget: `min(cpus, ram_budget, HARD_CAP)`.
205///
206/// RAM side: `(free_ram_mb * 50%) / RAM_PER_IO_TASK_MB`.
207pub fn compute_auto_budget() -> usize {
208    let cpus = cpu_count();
209    let ram_side = free_ram_mb()
210        .map(|mb| {
211            let usable = mb.saturating_mul(50) / 100; // 50% safety margin
212            let tasks = usable / RAM_PER_IO_TASK_MB.max(1);
213            (tasks as usize).max(MIN_CONCURRENCY)
214        })
215        .unwrap_or(cpus);
216    cpus.min(ram_side).min(HARD_CAP).max(MIN_CONCURRENCY)
217}
218
219/// `Arc<Semaphore>` gate with [`effective_limit`] permits.
220pub fn io_semaphore() -> Arc<Semaphore> {
221    Arc::new(Semaphore::new(effective_limit()))
222}
223
224/// Semaphore with an explicit permit count (already clamped by caller).
225pub fn semaphore_with(permits: usize) -> Arc<Semaphore> {
226    Arc::new(Semaphore::new(permits.clamp(MIN_CONCURRENCY, HARD_CAP)))
227}
228
229/// Run a list of futures with bounded concurrency via **`Arc<Semaphore>`**.
230///
231/// Gate of record (rules_rust_paralelismo): each future acquires one permit with
232/// [`Semaphore::acquire_owned`] before polling body work; the permit is dropped
233/// (RAII) when the future completes. Internally composed with
234/// `buffer_unordered` so the stream polls at most `limit` futures, **and** the
235/// Semaphore is the admission control agents / tests observe.
236///
237/// Results are returned in **completion order**. Prefer this over unbounded
238/// `join_all` on collections of unknown size.
239///
240/// # Cancel safety
241///
242/// Each future is polled independently; dropping the returned future cancels
243/// in-flight work at the next await point of those futures (permits return).
244///
245/// # Observability
246///
247/// Host-local only: `tracing::debug!` of `available_permits` at start (no remote
248/// OTel — product law).
249/// # Gate pattern
250///
251/// Uses [`Semaphore::acquire`] (not `acquire_owned`) because callers pass
252/// borrowed CDP futures that are **not** `'static`. Work stays on the same
253/// poller (`buffer_unordered`); permit is held for the future body and dropped
254/// via RAII. For `tokio::spawn` fan-out, call sites use `acquire_owned` +
255/// `JoinSet` instead (batch/crawl).
256pub async fn join_bounded<F, T>(futures: Vec<F>, limit: usize) -> Vec<T>
257where
258    F: Future<Output = T>,
259{
260    let limit = limit.clamp(MIN_CONCURRENCY, HARD_CAP);
261    let n = futures.len();
262    let sem = Arc::new(Semaphore::new(limit));
263    tracing::debug!(
264        available_permits = sem.available_permits(),
265        limit,
266        n,
267        "join_bounded fan-out (Arc<Semaphore>::acquire)"
268    );
269    let gated = futures.into_iter().map(|f| {
270        let sem = Arc::clone(&sem);
271        async move {
272            // acquire (same-scope): RAII permit for the duration of f.
273            let _permit = sem.acquire().await.ok();
274            f.await
275        }
276    });
277    stream::iter(gated).buffer_unordered(limit).collect().await
278}
279
280/// Like [`join_bounded`] but preserves input order via indexed futures.
281pub async fn join_bounded_ordered<F, T>(futures: Vec<F>, limit: usize) -> Vec<T>
282where
283    F: Future<Output = T>,
284{
285    let limit = limit.clamp(MIN_CONCURRENCY, HARD_CAP);
286    let indexed: Vec<_> = futures
287        .into_iter()
288        .enumerate()
289        .map(|(i, f)| async move { (i, f.await) })
290        .collect();
291    let mut pairs = join_bounded(indexed, limit).await;
292    pairs.sort_by_key(|(i, _)| *i);
293    pairs.into_iter().map(|(_, v)| v).collect()
294}
295
296/// Walk / Rayon thread hint: budget capped by CPUs (respects `--max-concurrency`).
297pub fn walk_threads() -> usize {
298    effective_limit_capped(cpu_count())
299}
300
301/// Suggested Rayon thread count (respects `RAYON_NUM_THREADS` via Rayon itself
302/// when using the global pool; this value is for explicit `ThreadPoolBuilder`).
303pub fn rayon_threads() -> usize {
304    effective_limit_capped(cpu_count())
305}
306
307/// Run a CPU-bound closure on a Rayon pool sized to the process budget.
308///
309/// Prefer `par_iter` at call sites when the work is already an iterator map.
310/// This helper is for one-shot “run this block under a sized pool” cases.
311pub fn install_rayon_pool_once() {
312    // Rayon global pool: build at most once. If RAYON_NUM_THREADS is set, Rayon
313    // honors it; otherwise we pin to our budget so a 128-core box does not
314    // spawn 128 workers for a one-shot CLI scan.
315    static INIT: OnceLock<()> = OnceLock::new();
316    INIT.get_or_init(|| {
317        let n = rayon_threads();
318        // Ignore error if another crate already built the global pool.
319        let _ = rayon::ThreadPoolBuilder::new()
320            .num_threads(n)
321            .thread_name(|i| format!("bac-rayon-{i}"))
322            .build_global();
323    });
324}
325
326/// Browser Tokio worker threads: enough for CDP event fan-out, capped.
327///
328/// Scales with budget but stays small (CDP is I/O; extra workers beyond ~8
329/// mostly burn RSS on a one-shot process).
330pub fn browser_worker_threads() -> usize {
331    effective_limit_capped(8).max(2)
332}
333
334/// Cap for Tokio `max_blocking_threads` on the browser runtime.
335pub fn browser_max_blocking_threads() -> usize {
336    effective_limit_capped(16).max(4)
337}
338
339/// Below this length, [`map_cpu`] stays sequential (rule: never parallelize when
340/// cost ≪ coordination overhead). Measured trade-off for one-shot CLI filters.
341pub const CPU_MAP_THRESHOLD: usize = 32;
342
343/// Write bytes on the Tokio blocking pool (docsrs: never pin async workers with
344/// `std::fs` for non-trivial payloads).
345///
346/// # Cancel safety
347///
348/// `spawn_blocking` work is **not** abortable after start (docsrs). Cancellation
349/// must cut admission at the async gate; this helper is for short-lived disk I/O.
350pub async fn write_bytes_blocking(
351    path: std::path::PathBuf,
352    bytes: Vec<u8>,
353) -> Result<(), std::io::Error> {
354    tokio::task::spawn_blocking(move || {
355        if let Some(parent) = path.parent() {
356            if !parent.as_os_str().is_empty() {
357                std::fs::create_dir_all(parent)?;
358            }
359        }
360        std::fs::write(&path, bytes)
361    })
362    .await
363    .map_err(|e| std::io::Error::other(format!("write_bytes_blocking join: {e}")))?
364}
365
366/// `create_dir_all` on the blocking pool.
367pub async fn create_dir_all_blocking(path: std::path::PathBuf) -> Result<(), std::io::Error> {
368    tokio::task::spawn_blocking(move || std::fs::create_dir_all(path))
369        .await
370        .map_err(|e| std::io::Error::other(format!("create_dir_all_blocking join: {e}")))?
371}
372
373/// Read a file fully on the blocking pool.
374pub async fn read_bytes_blocking(path: std::path::PathBuf) -> Result<Vec<u8>, std::io::Error> {
375    tokio::task::spawn_blocking(move || std::fs::read(path))
376        .await
377        .map_err(|e| std::io::Error::other(format!("read_bytes_blocking join: {e}")))?
378}
379
380/// Read a UTF-8 file on the blocking pool (PAR-77: never `fs::read_to_string` on async worker).
381pub async fn read_to_string_blocking(path: std::path::PathBuf) -> Result<String, std::io::Error> {
382    tokio::task::spawn_blocking(move || std::fs::read_to_string(path))
383        .await
384        .map_err(|e| std::io::Error::other(format!("read_to_string_blocking join: {e}")))?
385}
386
387/// `rename` on the blocking pool (PAR-80: state rotation must not pin async workers).
388pub async fn rename_blocking(
389    from: std::path::PathBuf,
390    to: std::path::PathBuf,
391) -> Result<(), std::io::Error> {
392    tokio::task::spawn_blocking(move || std::fs::rename(from, to))
393        .await
394        .map_err(|e| std::io::Error::other(format!("rename_blocking join: {e}")))?
395}
396
397/// Sync write helper for **outer CLI dispatch** (no active Tokio worker). Prefer
398/// [`write_bytes_blocking`] when inside `async fn` / `block_on_*`.
399pub fn write_bytes_sync(path: &std::path::Path, bytes: &[u8]) -> Result<(), std::io::Error> {
400    if let Some(parent) = path.parent() {
401        if !parent.as_os_str().is_empty() {
402            std::fs::create_dir_all(parent)?;
403        }
404    }
405    std::fs::write(path, bytes)
406}
407
408/// CPU map over a slice: sequential when `items.len() < CPU_MAP_THRESHOLD`, else
409/// Rayon under [`install_rayon_pool_once`] (pool sized to budget).
410///
411/// Prefer this over ad-hoc `par_iter` so small collections never pay Rayon overhead.
412pub fn map_cpu<T, R, F>(items: &[T], f: F) -> Vec<R>
413where
414    T: Sync,
415    R: Send,
416    F: Fn(&T) -> R + Sync + Send,
417{
418    if items.len() < CPU_MAP_THRESHOLD {
419        return items.iter().map(f).collect();
420    }
421    install_rayon_pool_once();
422    use rayon::prelude::*;
423    items.par_iter().map(f).collect()
424}
425
426/// Like [`map_cpu`] but for owned items that are consumed (into_par_iter).
427pub fn map_cpu_owned<T, R, F>(items: Vec<T>, f: F) -> Vec<R>
428where
429    T: Send,
430    R: Send,
431    F: Fn(T) -> R + Sync + Send,
432{
433    if items.len() < CPU_MAP_THRESHOLD {
434        return items.into_iter().map(f).collect();
435    }
436    install_rayon_pool_once();
437    use rayon::prelude::*;
438    items.into_par_iter().map(f).collect()
439}
440
441/// Parallel filter with the same threshold rule as [`map_cpu`] (PAR-84).
442///
443/// Sequential when `items.len() < CPU_MAP_THRESHOLD` so small console/net buffers
444/// never pay Rayon coordination overhead.
445pub fn filter_cpu<T, F>(items: Vec<T>, pred: F) -> Vec<T>
446where
447    T: Send,
448    F: Fn(&T) -> bool + Sync + Send,
449{
450    if items.len() < CPU_MAP_THRESHOLD {
451        return items.into_iter().filter(pred).collect();
452    }
453    install_rayon_pool_once();
454    use rayon::prelude::*;
455    items.into_par_iter().filter(pred).collect()
456}
457
458/// In-place sort with Rayon when `items.len() >= CPU_MAP_THRESHOLD` (PAR-94).
459///
460/// Uses `par_sort_unstable` for large collections (rule: prefer unstable sort
461/// when total order equality is acceptable for agent determinism of equal keys).
462/// Small slices stay sequential to avoid coordination overhead.
463pub fn sort_cpu<T>(items: &mut [T])
464where
465    T: Ord + Send,
466{
467    if items.len() < CPU_MAP_THRESHOLD {
468        items.sort_unstable();
469        return;
470    }
471    install_rayon_pool_once();
472    use rayon::prelude::*;
473    items.par_sort_unstable();
474}
475
476/// In-place sort by key with the same threshold as [`sort_cpu`] (PAR-94).
477pub fn sort_by_key_cpu<T, K, F>(items: &mut [T], f: F)
478where
479    T: Send,
480    K: Ord,
481    F: Fn(&T) -> K + Sync + Send,
482{
483    if items.len() < CPU_MAP_THRESHOLD {
484        items.sort_by_key(f);
485        return;
486    }
487    install_rayon_pool_once();
488    use rayon::prelude::*;
489    items.par_sort_unstable_by_key(f);
490}
491
492/// In-place sort with comparator; Rayon when large (PAR-94).
493pub fn sort_by_cpu<T, F>(items: &mut [T], compare: F)
494where
495    T: Send,
496    F: Fn(&T, &T) -> std::cmp::Ordering + Sync + Send,
497{
498    if items.len() < CPU_MAP_THRESHOLD {
499        items.sort_by(&compare);
500        return;
501    }
502    install_rayon_pool_once();
503    use rayon::prelude::*;
504    items.par_sort_by(compare);
505}
506
507/// Snapshot of budget for doctor / `--json` diagnostics.
508pub fn budget_report() -> serde_json::Value {
509    serde_json::json!({
510        "effective": effective_limit(),
511        "override": OVERRIDE.load(Ordering::Relaxed),
512        "auto": compute_auto_budget(),
513        "cpus": cpu_count(),
514        "free_ram_mb": free_ram_mb(),
515        "ram_per_io_task_mb": RAM_PER_IO_TASK_MB,
516        "hard_cap": HARD_CAP,
517        "cpu_map_threshold": CPU_MAP_THRESHOLD,
518        "browser_workers": browser_worker_threads(),
519        "formula": "min(cpus, (free_ram_mb*50%)/64, 64); --max-concurrency overrides",
520        "workload_default": "I/O-bound (CDP/HTTP) + CPU-bound (sg scan via rayon)",
521        "local_available_permits_note": "host-local only; no remote OTel of permits (product law)",
522        "commands": command_workload_matrix(),
523    })
524}
525
526/// Entry helper for [`command_by_command_matrix`] (avoids deep `json!` recursion).
527fn cmd_entry(class: &str, gate: Option<&str>, reason: Option<&str>) -> serde_json::Value {
528    let mut m = serde_json::Map::new();
529    m.insert("class".into(), serde_json::Value::String(class.into()));
530    if let Some(g) = gate {
531        m.insert("gate".into(), serde_json::Value::String(g.into()));
532    }
533    if let Some(r) = reason {
534        m.insert("reason".into(), serde_json::Value::String(r.into()));
535    }
536    serde_json::Value::Object(m)
537}
538
539/// Per-command map built programmatically (Pass 24; large object not via `json!`).
540fn command_by_command_matrix() -> serde_json::Value {
541    // (name, class, gate?, reason?) — gates MUST match code (PAR-73 honesty).
542    // Nested multi-item subcommands use dotted keys (PAR-76).
543    let rows: &[(&str, &str, Option<&str>, Option<&str>)] = &[
544        // Top-level families
545        (
546            "doctor",
547            "sequential_justified",
548            None,
549            Some("cheap path/which probes; cost ≪ Rayon (N-144/PAR-57)"),
550        ),
551        ("commands", "sequential_justified", None, Some("meta inventory")),
552        ("schema", "sequential_justified", None, Some("meta schema emit")),
553        ("version", "sequential_justified", None, Some("meta")),
554        ("locale", "sequential_justified", None, Some("meta")),
555        ("goto", "sequential_justified", None, Some("single interactive act (N-138)")),
556        ("view", "mixed", Some("join_bounded multi-ref CDP"), Some("snapshot internal fan-out")),
557        ("press", "sequential_justified", None, Some("single DOM act (N-135)")),
558        ("click-at", "sequential_justified", None, Some("single coordinate act")),
559        ("write", "sequential_justified", None, Some("single fill act")),
560        ("keys", "sequential_justified", None, Some("ordered key events")),
561        ("type", "sequential_justified", None, Some("ordered chars (N-141)")),
562        ("wait", "sequential_justified", None, Some("poll loop single page")),
563        ("hover", "sequential_justified", None, Some("single act")),
564        ("drag", "sequential_justified", None, Some("ordered pointer path")),
565        ("fill-form", "sequential_justified", None, Some("DOM focus order (N-135)")),
566        ("select-option", "sequential_justified", None, Some("single act")),
567        ("pick", "sequential_justified", None, Some("single act")),
568        ("upload", "sequential_justified", None, Some("single act")),
569        ("back", "sequential_justified", None, Some("single navigation")),
570        ("forward", "sequential_justified", None, Some("single navigation")),
571        ("reload", "sequential_justified", None, Some("single navigation")),
572        ("eval", "mixed", Some("write_bytes_blocking on --file"), Some("single JS; disk off async")),
573        (
574            "grab",
575            "mixed",
576            Some("join_bounded multi-rect + save_screenshot_async"),
577            Some("multi-target CDP"),
578        ),
579        ("print-pdf", "mixed", Some("write_bytes_blocking"), Some("single PDF off async worker")),
580        ("monitor", "sequential_justified", None, Some("single baseline hash path")),
581        (
582            "run",
583            "sequential_justified",
584            None,
585            Some("ordered script (N-134); internal steps may fan-out"),
586        ),
587        ("exec", "sequential_justified", None, Some("ordered script (N-134)")),
588        ("extract", "sequential_justified", None, Some("single target or single LLM call")),
589        ("text", "sequential_justified", None, Some("single target")),
590        ("scroll", "sequential_justified", None, Some("single act")),
591        ("cookie", "sequential_justified", None, Some("CDP cookie ops single session")),
592        ("attr", "sequential_justified", None, Some("single target")),
593        ("assert", "sequential_justified", None, Some("single check; console filters use filter_cpu")),
594        (
595            "console",
596            "mixed",
597            Some("filter_cpu when large"),
598            Some("buffer filter threshold; dump write_bytes_blocking"),
599        ),
600        (
601            "net",
602            "mixed",
603            Some("filter_cpu when large"),
604            Some("buffer filter threshold; get path write_bytes_blocking"),
605        ),
606        (
607            "page",
608            "sequential_justified",
609            None,
610            Some("tab ops on single browser; multi-attach at launch"),
611        ),
612        ("dialog", "sequential_justified", None, Some("single dialog handle")),
613        (
614            "scrape",
615            "mixed",
616            Some("spawn_blocking parse (http)"),
617            Some("single URL; batch for multi"),
618        ),
619        (
620            "batch-scrape",
621            "parallel_io",
622            Some("JoinSet+Semaphore http; browser sequential N-129"),
623            None,
624        ),
625        (
626            "crawl",
627            "parallel_io",
628            Some("JoinSet+Semaphore http; browser sequential N-129"),
629            None,
630        ),
631        ("map", "parallel_io", Some("crawl_http under budget"), None),
632        ("search", "parallel_io", Some("scrape/map under budget"), None),
633        ("parse", "mixed", Some("CPU parse sync path"), Some("single file")),
634        ("qr", "sequential_justified", None, Some("single payload")),
635        (
636            "find-paths",
637            "parallel_cpu",
638            Some("WalkBuilder.threads + multi-root flat_map (no Mutex)"),
639            None,
640        ),
641        (
642            "sg-scan",
643            "parallel_cpu",
644            Some("multi-root par + par_iter files + sort_cpu"),
645            None,
646        ),
647        ("sg-rewrite", "mixed", Some("dry-run par+sort_cpu; --apply sequential N-136"), None),
648        ("sheet-write", "sequential_justified", None, Some("single writer N-137")),
649        (
650            "mitm",
651            "mixed",
652            Some("CA read_to_string_blocking; map_cpu+sort_cpu list filters"),
653            None,
654        ),
655        ("workflow", "sequential_justified", None, Some("SQLite single-writer N-130")),
656        ("config", "sequential_justified", None, Some("single config file")),
657        ("emulate", "sequential_justified", None, Some("single CDP device")),
658        ("resize", "sequential_justified", None, Some("single viewport")),
659        (
660            "perf",
661            "mixed",
662            Some("write_bytes_blocking stop; map_cpu insight when large"),
663            None,
664        ),
665        ("lighthouse", "sequential_justified", None, Some("single subprocess N-140")),
666        (
667            "screencast",
668            "mixed",
669            Some("spawn_blocking+rayon frames on stop"),
670            None,
671        ),
672        (
673            "heap",
674            "mixed",
675            Some("node parse par; idom seq N-142; map_cpu/sort_cpu; write_bytes_blocking"),
676            None,
677        ),
678        (
679            "extension",
680            "mixed",
681            Some("join_bounded multi-closeTarget"),
682            Some("single load; multi-target unload fan-out"),
683        ),
684        ("devtools3p", "sequential_justified", None, Some("single bridge")),
685        ("webmcp", "sequential_justified", None, Some("single tool call")),
686        ("completions", "sequential_justified", None, Some("meta emit")),
687        ("man", "sequential_justified", None, Some("meta emit")),
688        ("install", "sequential_justified", None, Some("few version dirs")),
689        (
690            "state",
691            "mixed",
692            Some("write/read_bytes_blocking; multi-origin sequential N-143"),
693            None,
694        ),
695        ("cache", "sequential_justified", None, Some("single key ops")),
696        (
697            "residual",
698            "mixed",
699            Some("index_proc_cmdlines once + map_cpu check/wipe"),
700            Some("PAR-89/90: never N×/proc under Rayon"),
701        ),
702        // Nested multi-item / disk subcommands (PAR-76)
703        (
704            "console.list",
705            "mixed",
706            Some("filter_cpu when large"),
707            Some("type/sw filter on capture buffer"),
708        ),
709        (
710            "console.dump",
711            "mixed",
712            Some("write_bytes_blocking"),
713            Some("serialize+disk off async/block_on worker"),
714        ),
715        (
716            "net.list",
717            "mixed",
718            Some("filter_cpu when large"),
719            Some("resource_type filter on capture buffer"),
720        ),
721        (
722            "net.get",
723            "mixed",
724            Some("write_bytes_blocking on --path"),
725            Some("optional request/response path dumps"),
726        ),
727        (
728            "heap.dup-strings",
729            "parallel_cpu",
730            Some("map_cpu"),
731            Some("independent string score after idom"),
732        ),
733        (
734            "heap.summary",
735            "mixed",
736            Some("map_cpu when large"),
737            Some("offline parse; graph passes sequential"),
738        ),
739        (
740            "heap.take",
741            "mixed",
742            Some("write_bytes_blocking"),
743            Some("CDP chunks join then disk off async"),
744        ),
745        (
746            "mitm.domains",
747            "parallel_cpu",
748            Some("map_cpu"),
749            Some("host extract over capture items"),
750        ),
751        (
752            "mitm.apis",
753            "parallel_cpu",
754            Some("map_cpu"),
755            Some("API classify over capture items"),
756        ),
757        (
758            "assert.console",
759            "mixed",
760            Some("filter_cpu when large"),
761            Some("level filter on console buffer"),
762        ),
763        (
764            "assert.console-empty",
765            "sequential_justified",
766            None,
767            Some("count check; cost ≪ overhead"),
768        ),
769        (
770            "assert.console-no-match",
771            "mixed",
772            Some("filter_cpu when large"),
773            Some("pattern filter on console buffer"),
774        ),
775        (
776            "state.save",
777            "mixed",
778            Some("write_bytes_blocking + create_dir_all_blocking"),
779            Some("CDP collect then disk off async"),
780        ),
781        (
782            "state.load",
783            "mixed",
784            Some("read_bytes_blocking; multi-origin sequential N-143"),
785            Some("disk off async; navigates sequential"),
786        ),
787        (
788            "state.list",
789            "sequential_justified",
790            None,
791            Some("few session files; cost ≪ Rayon"),
792        ),
793        (
794            "perf.stop",
795            "mixed",
796            Some("write_bytes_blocking"),
797            Some("trace dump off async worker"),
798        ),
799        (
800            "perf.insight",
801            "mixed",
802            Some("map_cpu when large"),
803            Some("offline event fold with threshold"),
804        ),
805        (
806            "screencast.stop",
807            "mixed",
808            Some("spawn_blocking+rayon frames"),
809            Some("decode+write N frames"),
810        ),
811    ];
812    let mut map = serde_json::Map::new();
813    for (name, class, gate, reason) in rows {
814        map.insert((*name).into(), cmd_entry(class, *gate, *reason));
815    }
816    serde_json::Value::Object(map)
817}
818
819/// Per-command parallelism posture (agent discovery / doctor).
820///
821/// Every multi-item command either fans out under [`effective_limit`] or has an
822/// explicit sequential justification (single CDP session, single-writer journal,
823/// atomic rewrite apply, or cost ≪ coordination overhead).
824///
825/// Pass 24–25: `by_command` maps each top-level CLI command **and** multi-item
826/// nested subcommands so agents never treat sequential single-act paths as
827/// forgotten parallelism. Gates must match code (PAR-73 honesty).
828pub fn command_workload_matrix() -> serde_json::Value {
829    let mut root = serde_json::Map::new();
830    root.insert(
831        "parallel_io".into(),
832        serde_json::json!([
833            "batch-scrape --engine http (JoinSet+Semaphore; parse via spawn_blocking)",
834            "crawl --engine http (bounded frontier; discovery under same permit)",
835            "map / search (HTTP; crawl/scrape under budget)",
836            "view/snapshot multi-ref CDP resolve (join_bounded+Semaphore)",
837            "grab multi-target rect resolve (join_bounded+Semaphore)",
838            "find-paths (WalkBuilder.threads=walk_threads; multi-root par)",
839            "robots shared client keep-alive under batch",
840            "network sanitize multi-page (join_bounded CDP navigate)",
841            "browser multi-target attach (join_bounded_ordered)",
842            "cdp page forwarders multi-page (join_bounded)",
843            "screencast stop frames (spawn_blocking+rayon decode/write)"
844        ]),
845    );
846    root.insert(
847        "parallel_cpu".into(),
848        serde_json::json!([
849            "sg-scan (rayon par_iter; multi-root collect par)",
850            "sg-rewrite dry-run (rayon par_iter); --apply sequential",
851            "heap score/filter independent passes (map_cpu); idom/RPO sequential",
852            "mitm domains/apis filter when items >= CPU_MAP_THRESHOLD",
853            "console/net list filter_cpu when buffer >= CPU_MAP_THRESHOLD",
854            "residual multi-candidate scavenge when >= threshold",
855            "perf_insight top-level events map_cpu when large"
856        ]),
857    );
858    root.insert(
859        "sequential_justified".into(),
860        serde_json::json!({
861            "batch-scrape --engine browser": "single residual Chrome / one Page (N-129); use --engine http for fan-out",
862            "crawl --engine browser": "single CDP Page session (N-129)",
863            "run / exec multi-step": "ordered script semantics + fail-fast (N-134)",
864            "workflow run/resume": "SQLite journal single-writer (N-130); fan-out inside steps",
865            "fill-form": "DOM focus/state must be sequential on one Page (N-135)",
866            "sg-rewrite --apply": "atomic writers must not race the same tree (N-136)",
867            "sheet-write": "single workbook writer (rust_xlsxwriter not Sync) (N-137)",
868            "qr encode/decode": "single payload; multi-grid decode rare and cheap",
869            "goto/press/type/click/keys/…": "single interactive act (N-138); cost ≪ spawn",
870            "doctor/commands/schema/version/locale": "meta; doctor probes cheap — sequential (N-144); cost ≪ Rayon",
871            "lighthouse": "single external subprocess (N-140)",
872            "mitm start/capture": "one proxy task JoinHandle awaited; not multi-URL fan-out",
873            "llm local": "single request; OnceLock HTTP client (N-139)",
874            "residual FINALIZE": "map_cpu when candidates large; else cost ≪ overhead",
875            "install chrome discovery": "few version dirs; sequential OK (N-cost)",
876            "state list/clear/clean": "few session files; cost ≪ Rayon (PAR-72)",
877            "state load multi-origin": "single CDP session navigate — sequential (N-143)",
878            "cache get/put": "single key; Mutex short critical section no .await",
879            "parse spreadsheet multi-sheet": "calamine Reader not Sync — sequential (PAR-59)",
880            "type char-a-char": "ordered input semantics (N-141)",
881            "snapshot tree build": "parent/child links ordered — sequential (N-145)"
882        }),
883    );
884    root.insert("by_command".into(), command_by_command_matrix());
885    root.insert(
886        "bound_everywhere".into(),
887        serde_json::Value::String(
888            "JoinSet+Arc<Semaphore>::acquire_owned | join_bounded | WalkBuilder.threads(walk_threads) | rayon pool sized to budget | map_cpu/filter_cpu/sort_cpu threshold".into(),
889        ),
890    );
891    root.insert(
892        "cancel".into(),
893        serde_json::Value::String(
894            "Lifecycle CancellationToken checked mid batch/crawl acquire".into(),
895        ),
896    );
897    root.insert(
898        "helpers".into(),
899        serde_json::json!([
900            "write_bytes_blocking",
901            "write_bytes_sync",
902            "create_dir_all_blocking",
903            "read_bytes_blocking",
904            "read_to_string_blocking",
905            "rename_blocking",
906            "map_cpu",
907            "filter_cpu",
908            "sort_cpu",
909            "sort_by_cpu",
910            "sort_by_key_cpu",
911            "join_bounded"
912        ]),
913    );
914    root.insert(
915        "na_product_law".into(),
916        serde_json::json!([
917            "multi-process Chrome pool (N-129/N-154)",
918            "workflow multi-writer SQLite (N-130/N-155)",
919            "systemd-run MemoryMax default (N-121)",
920            "remote OTel available_permits (N-124)",
921            "loom GHA (N-122)",
922            "state multi-origin parallel same session (N-143)",
923            "heap idom/RPO blind par_iter (N-142/N-152)",
924            "doctor cheap probes Rayon (N-144/N-156)",
925            "snapshot tree build blind par (N-145/N-153)",
926            "DOM single-act parallel (N-151)",
927            "mitm block/allow rules sync CLI (N-158)"
928        ]),
929    );
930    serde_json::Value::Object(root)
931}
932
933/// Resolve permits for a fan-out: `0` → process budget; else clamp to hard cap.
934pub fn resolve_permits(requested: usize) -> usize {
935    if requested == 0 {
936        effective_limit()
937    } else {
938        requested.clamp(MIN_CONCURRENCY, HARD_CAP)
939    }
940}
941
942#[cfg(test)]
943mod tests {
944    use super::*;
945    use std::sync::atomic::{AtomicUsize, Ordering as AtomOrd};
946    use std::sync::Arc;
947
948    #[test]
949    fn auto_budget_is_bounded() {
950        let b = compute_auto_budget();
951        assert!(b >= MIN_CONCURRENCY);
952        assert!(b <= HARD_CAP);
953    }
954
955    #[test]
956    fn install_override_clamps() {
957        install_limit(0);
958        assert_eq!(OVERRIDE.load(Ordering::Relaxed), 0);
959        install_limit(1);
960        assert_eq!(effective_limit(), 1);
961        install_limit(9999);
962        assert_eq!(effective_limit(), HARD_CAP);
963        // Reset for other tests in the same process.
964        install_limit(0);
965    }
966
967    #[test]
968    fn browser_workers_at_least_two() {
969        install_limit(1);
970        assert!(browser_worker_threads() >= 2);
971        install_limit(0);
972    }
973
974    #[tokio::test]
975    async fn join_bounded_respects_peak() {
976        let peak = Arc::new(AtomicUsize::new(0));
977        let current = Arc::new(AtomicUsize::new(0));
978        let limit = 3usize;
979        let mut futs = Vec::new();
980        for _ in 0..12 {
981            let peak = Arc::clone(&peak);
982            let current = Arc::clone(&current);
983            futs.push(async move {
984                let n = current.fetch_add(1, AtomOrd::SeqCst) + 1;
985                peak.fetch_max(n, AtomOrd::SeqCst);
986                tokio::time::sleep(std::time::Duration::from_millis(15)).await;
987                current.fetch_sub(1, AtomOrd::SeqCst);
988                1u32
989            });
990        }
991        let out = join_bounded(futs, limit).await;
992        assert_eq!(out.len(), 12);
993        assert!(
994            peak.load(AtomOrd::SeqCst) <= limit,
995            "peak {} exceeded limit {}",
996            peak.load(AtomOrd::SeqCst),
997            limit
998        );
999    }
1000
1001    #[tokio::test]
1002    async fn join_bounded_ordered_preserves_order() {
1003        let futs: Vec<_> = (0..8u32)
1004            .map(|i| async move {
1005                tokio::time::sleep(std::time::Duration::from_millis((8 - i) as u64)).await;
1006                i
1007            })
1008            .collect();
1009        let out = join_bounded_ordered(futs, 4).await;
1010        assert_eq!(out, (0..8).collect::<Vec<_>>());
1011    }
1012
1013    #[test]
1014    fn semaphore_has_effective_permits() {
1015        install_limit(4);
1016        let s = io_semaphore();
1017        assert_eq!(s.available_permits(), 4);
1018        install_limit(0);
1019    }
1020
1021    #[test]
1022    fn resolve_permits_zero_is_effective() {
1023        install_limit(3);
1024        assert_eq!(resolve_permits(0), 3);
1025        assert_eq!(resolve_permits(2), 2);
1026        assert_eq!(resolve_permits(9999), HARD_CAP);
1027        install_limit(0);
1028    }
1029
1030    #[test]
1031    fn command_matrix_lists_parallel_and_sequential() {
1032        let m = command_workload_matrix();
1033        assert!(m.get("parallel_io").and_then(|v| v.as_array()).is_some());
1034        assert!(m
1035            .get("sequential_justified")
1036            .and_then(|v| v.as_object())
1037            .is_some());
1038    }
1039
1040    #[test]
1041    #[cfg(target_os = "linux")]
1042    fn free_ram_linux_reads_meminfo() {
1043        // On a normal Linux host MemAvailable is present; CI containers too.
1044        let mb = free_ram_mb();
1045        assert!(mb.is_some(), "expected MemAvailable/MemFree on Linux");
1046        assert!(mb.unwrap() > 0);
1047    }
1048
1049    #[tokio::test]
1050    async fn semaphore_permit_returns_after_panic_in_joinset() {
1051        // Rule checklist: panic in task must not permanently leak permits.
1052        let limit = 2usize;
1053        let sem = Arc::new(Semaphore::new(limit));
1054        let mut set: tokio::task::JoinSet<()> = tokio::task::JoinSet::new();
1055        for i in 0..4 {
1056            let permit = Arc::clone(&sem)
1057                .acquire_owned()
1058                .await
1059                .expect("sem open");
1060            set.spawn(async move {
1061                let _permit = permit;
1062                if i == 1 {
1063                    panic!("intentional concurrency panic");
1064                }
1065                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1066            });
1067        }
1068        let mut panics = 0u32;
1069        while let Some(joined) = set.join_next().await {
1070            if let Err(e) = joined {
1071                if e.is_panic() {
1072                    panics += 1;
1073                }
1074            }
1075        }
1076        assert_eq!(panics, 1);
1077        assert_eq!(
1078            sem.available_permits(),
1079            limit,
1080            "all permits must return after JoinSet drain (incl. panic tasks)"
1081        );
1082    }
1083
1084    #[test]
1085    fn walk_threads_never_exceeds_hard_cap_or_cpus() {
1086        // Pure bound: walk threads ≤ min(HARD_CAP, cpus) regardless of override races.
1087        let w = walk_threads();
1088        assert!(w >= MIN_CONCURRENCY);
1089        assert!(w <= HARD_CAP);
1090        assert!(w <= cpu_count().max(MIN_CONCURRENCY));
1091    }
1092
1093    #[test]
1094    fn command_matrix_has_na_and_cancel() {
1095        let m = command_workload_matrix();
1096        assert!(m.get("na_product_law").and_then(|v| v.as_array()).is_some());
1097        assert!(m.get("cancel").and_then(|v| v.as_str()).is_some());
1098        let seq = m
1099            .get("sequential_justified")
1100            .and_then(|v| v.as_object())
1101            .expect("seq");
1102        assert!(seq.contains_key("lighthouse"));
1103        assert!(seq.contains_key("mitm start/capture"));
1104    }
1105
1106    #[test]
1107    fn map_cpu_sequential_below_threshold() {
1108        let items: Vec<u32> = (0..10).collect();
1109        let out = map_cpu(&items, |x| x * 2);
1110        assert_eq!(out, (0..10).map(|x| x * 2).collect::<Vec<_>>());
1111    }
1112
1113    #[test]
1114    fn map_cpu_parallel_above_threshold() {
1115        let items: Vec<u32> = (0..(CPU_MAP_THRESHOLD as u32 + 8)).collect();
1116        let out = map_cpu(&items, |x| x.saturating_add(1));
1117        assert_eq!(out.len(), items.len());
1118        assert_eq!(out[0], 1);
1119        assert_eq!(out[items.len() - 1], items[items.len() - 1] + 1);
1120    }
1121
1122    #[test]
1123    fn sort_cpu_orders_small_and_large() {
1124        // PAR-99: threshold path + parallel path both produce sorted output.
1125        let mut small = vec![3, 1, 2];
1126        sort_cpu(&mut small);
1127        assert_eq!(small, vec![1, 2, 3]);
1128        let mut large: Vec<u32> = (0..(CPU_MAP_THRESHOLD as u32 + 16)).rev().collect();
1129        sort_cpu(&mut large);
1130        assert!(large.windows(2).all(|w| w[0] <= w[1]));
1131        assert_eq!(large.first().copied(), Some(0));
1132    }
1133
1134    #[test]
1135    fn sort_by_key_cpu_reverse_counts() {
1136        let mut items = vec![("a", 2u64), ("b", 9u64), ("c", 1u64)];
1137        sort_by_key_cpu(&mut items, |b| std::cmp::Reverse(b.1));
1138        assert_eq!(items[0].1, 9);
1139        assert_eq!(items[2].1, 1);
1140    }
1141
1142    #[test]
1143    fn matrix_residual_mentions_index_proc() {
1144        let m = command_workload_matrix();
1145        let by = m.get("by_command").and_then(|v| v.as_object()).expect("by");
1146        let residual = by.get("residual").expect("residual");
1147        let gate = residual.get("gate").and_then(|v| v.as_str()).unwrap_or("");
1148        assert!(
1149            gate.contains("index_proc") || gate.contains("map_cpu"),
1150            "residual gate must document index-once scavenge: {gate}"
1151        );
1152        let helpers = m
1153            .get("helpers")
1154            .and_then(|v| v.as_array())
1155            .expect("helpers");
1156        let has_sort = helpers.iter().any(|h| h.as_str() == Some("sort_cpu"));
1157        assert!(has_sort, "helpers must list sort_cpu");
1158    }
1159
1160    #[test]
1161    fn by_command_covers_inventory_minimum() {
1162        let m = command_workload_matrix();
1163        let by = m
1164            .get("by_command")
1165            .and_then(|v| v.as_object())
1166            .expect("by_command");
1167        for key in [
1168            "doctor",
1169            "goto",
1170            "view",
1171            "batch-scrape",
1172            "crawl",
1173            "find-paths",
1174            "sg-scan",
1175            "screencast",
1176            "heap",
1177            "workflow",
1178            "run",
1179            "mitm",
1180            "state",
1181            "residual",
1182            "lighthouse",
1183            "grab",
1184            "map",
1185            "search",
1186            "console",
1187            "net",
1188            // Pass 25 nested multi-item
1189            "console.list",
1190            "console.dump",
1191            "net.list",
1192            "net.get",
1193            "heap.dup-strings",
1194            "mitm.domains",
1195            "state.load",
1196            "perf.insight",
1197            "screencast.stop",
1198        ] {
1199            assert!(by.contains_key(key), "missing by_command entry: {key}");
1200        }
1201        assert!(m.get("helpers").and_then(|v| v.as_array()).is_some());
1202    }
1203
1204    #[test]
1205    fn matrix_honesty_doctor_not_fake_map_cpu() {
1206        // PAR-73: doctor must not claim map_cpu when probes are sequential.
1207        let m = command_workload_matrix();
1208        let by = m
1209            .get("by_command")
1210            .and_then(|v| v.as_object())
1211            .expect("by_command");
1212        let doctor = by.get("doctor").and_then(|v| v.as_object()).expect("doctor");
1213        assert_eq!(
1214            doctor.get("class").and_then(|v| v.as_str()),
1215            Some("sequential_justified")
1216        );
1217        assert!(
1218            doctor.get("gate").is_none(),
1219            "doctor must not claim a parallel gate"
1220        );
1221        let helpers = m
1222            .get("helpers")
1223            .and_then(|v| v.as_array())
1224            .expect("helpers");
1225        let helper_names: Vec<&str> = helpers.iter().filter_map(|v| v.as_str()).collect();
1226        assert!(helper_names.contains(&"filter_cpu"));
1227        assert!(helper_names.contains(&"read_to_string_blocking"));
1228        assert!(helper_names.contains(&"rename_blocking"));
1229    }
1230
1231    #[test]
1232    fn filter_cpu_sequential_below_threshold() {
1233        let items: Vec<u32> = (0..10).collect();
1234        let out = filter_cpu(items, |x| x % 2 == 0);
1235        assert_eq!(out, vec![0, 2, 4, 6, 8]);
1236    }
1237
1238    #[test]
1239    fn filter_cpu_parallel_above_threshold() {
1240        let items: Vec<u32> = (0..(CPU_MAP_THRESHOLD as u32 + 16)).collect();
1241        let out = filter_cpu(items.clone(), |x| x % 2 == 0);
1242        assert_eq!(out.len(), items.len() / 2);
1243        assert_eq!(out[0], 0);
1244    }
1245
1246    #[tokio::test]
1247    async fn write_bytes_blocking_roundtrip() {
1248        let dir = tempfile::tempdir().expect("tmpdir");
1249        let path = dir.path().join("par24.bin");
1250        write_bytes_blocking(path.clone(), b"pass24".to_vec())
1251            .await
1252            .expect("write");
1253        let got = read_bytes_blocking(path).await.expect("read");
1254        assert_eq!(got, b"pass24");
1255    }
1256
1257    #[tokio::test]
1258    async fn read_to_string_and_rename_blocking_roundtrip() {
1259        let dir = tempfile::tempdir().expect("tmpdir");
1260        let path = dir.path().join("a.txt");
1261        let path2 = dir.path().join("b.txt");
1262        write_bytes_blocking(path.clone(), b"pass25".to_vec())
1263            .await
1264            .expect("write");
1265        let s = read_to_string_blocking(path.clone()).await.expect("read str");
1266        assert_eq!(s, "pass25");
1267        rename_blocking(path, path2.clone()).await.expect("rename");
1268        let s2 = read_to_string_blocking(path2).await.expect("read2");
1269        assert_eq!(s2, "pass25");
1270    }
1271}