1use 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
48pub const HARD_CAP: usize = 64;
50
51pub const MIN_CONCURRENCY: usize = 1;
53
54pub const RAM_PER_IO_TASK_MB: u64 = 64;
61
62static OVERRIDE: AtomicUsize = AtomicUsize::new(0);
64
65static AUTO_BUDGET: OnceLock<usize> = OnceLock::new();
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum WorkloadClass {
71 IoBound,
73 CpuBound,
75 Mixed,
77 Subprocess,
79}
80
81pub 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
94pub 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
103pub fn effective_limit_capped(cap: usize) -> usize {
105 effective_limit().min(cap.max(MIN_CONCURRENCY))
106}
107
108pub 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
116pub fn free_ram_mb() -> Option<u64> {
123 #[cfg(target_os = "linux")]
124 {
125 let text = std::fs::read_to_string("/proc/meminfo").ok()?;
126 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 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 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 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
204pub 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; 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
219pub fn io_semaphore() -> Arc<Semaphore> {
221 Arc::new(Semaphore::new(effective_limit()))
222}
223
224pub fn semaphore_with(permits: usize) -> Arc<Semaphore> {
226 Arc::new(Semaphore::new(permits.clamp(MIN_CONCURRENCY, HARD_CAP)))
227}
228
229pub 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 let _permit = sem.acquire().await.ok();
274 f.await
275 }
276 });
277 stream::iter(gated).buffer_unordered(limit).collect().await
278}
279
280pub 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
296pub fn walk_threads() -> usize {
298 effective_limit_capped(cpu_count())
299}
300
301pub fn rayon_threads() -> usize {
304 effective_limit_capped(cpu_count())
305}
306
307pub fn install_rayon_pool_once() {
312 static INIT: OnceLock<()> = OnceLock::new();
316 INIT.get_or_init(|| {
317 let n = rayon_threads();
318 let _ = rayon::ThreadPoolBuilder::new()
320 .num_threads(n)
321 .thread_name(|i| format!("bac-rayon-{i}"))
322 .build_global();
323 });
324}
325
326pub fn browser_worker_threads() -> usize {
331 effective_limit_capped(8).max(2)
332}
333
334pub fn browser_max_blocking_threads() -> usize {
336 effective_limit_capped(16).max(4)
337}
338
339pub const CPU_MAP_THRESHOLD: usize = 32;
342
343pub 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
366pub 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
373pub 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
380pub 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
387pub 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
397pub 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
408pub 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
426pub 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
441pub 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
458pub 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
476pub 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
492pub 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
507pub 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
526fn 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
539fn command_by_command_matrix() -> serde_json::Value {
541 let rows: &[(&str, &str, Option<&str>, Option<&str>)] = &[
544 (
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 (
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
819pub 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
933pub 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 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(¤t);
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 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 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 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 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 "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 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}