Skip to main content

harn_cli/
test_runner.rs

1use std::collections::{BTreeMap, HashSet};
2use std::fs;
3use std::future::Future;
4use std::path::{Path, PathBuf};
5use std::pin::Pin;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::{Arc, Condvar, Mutex};
8use std::thread;
9use std::time::Instant;
10
11use crate::env_guard::ScopedEnvVar;
12use crate::test_timing::DurationSummary;
13use crate::CLI_RUNTIME_STACK_SIZE;
14use harn_parser::SNode;
15use harn_vm::{IsolateValue, VmValue};
16
17mod discovery;
18mod execution;
19#[cfg(test)]
20mod fixture_tests;
21mod fixtures;
22mod reporting;
23mod session;
24mod skill_context;
25#[cfg(test)]
26mod tests;
27
28use discovery::{extract_cases_from_program, parse_program, seed_imported_enum_candidates};
29use execution::{execute_case, execute_file_fixture};
30use fixtures::{FixtureScope, TestFixture};
31pub use reporting::{
32    AggregateTimings, PhaseTimings, TestPhase, TestResult, TestSummary, TestTimeout,
33};
34pub use session::{TestRunSession, TestRunSessionStats};
35use skill_context::PreparedSkillContexts;
36
37#[derive(Clone, Debug)]
38pub enum TestRunEvent {
39    SuiteDiscovered {
40        total_tests: usize,
41        total_files: usize,
42        parallel: bool,
43        workers: usize,
44    },
45    LargeSequentialSuite {
46        total_tests: usize,
47        total_files: usize,
48    },
49    TestStarted {
50        name: String,
51        file: String,
52        test_index: usize,
53        total_tests: usize,
54    },
55    TestFinished(TestResult),
56}
57
58pub type TestRunProgress = Arc<dyn Fn(TestRunEvent) + Send + Sync>;
59
60const LARGE_SEQUENTIAL_TEST_THRESHOLD: usize = 50;
61const LARGE_SEQUENTIAL_FILE_THRESHOLD: usize = 10;
62const DEFAULT_PARALLEL_JOBS_CAP: usize = 8;
63const TIMINGS_CACHE_RELATIVE_PATH: &str = ".harn/test-timings.json";
64const HARN_TEST_JOBS_ENV: &str = "HARN_TEST_JOBS";
65const HARN_TEST_MAX_MS_ENV: &str = "HARN_TEST_MAX_MS";
66const HARN_TEST_MAX_EXECUTE_MS_ENV: &str = "HARN_TEST_MAX_EXECUTE_MS";
67
68/// Per-worker memory budget (MiB) used to cap *auto-detected* parallelism on
69/// memory-constrained or oversubscribed hosts. Overridable via
70/// `HARN_TEST_WORKER_MEMORY_MB`. A worker runs a full VM and may drive nested
71/// agent loops, so this is a deliberately conservative estimate. The cap only
72/// ever *lowers* the core-based default — it never raises it, and an explicit
73/// `--jobs` / `HARN_TEST_JOBS` always wins.
74const DEFAULT_WORKER_MEMORY_MB: u64 = 1024;
75const HARN_TEST_WORKER_MEMORY_MB_ENV: &str = "HARN_TEST_WORKER_MEMORY_MB";
76
77/// Memory (MiB) held back for the OS, the CI runner agent, and any co-tenant
78/// job, so an auto-sized suite cannot consume the last scrap of RAM and starve
79/// the runner's heartbeat. This is the failure mode behind the self-hosted
80/// "The operation was canceled" runner-loss cancellations: two runner agents
81/// share one box, two heavy jobs overcommit RAM + swap, and the kernel never
82/// fires the OOM-killer — instead a starved runner agent stops phoning home
83/// and the control plane declares the job lost.
84const RESERVED_SYSTEM_MEMORY_MB: u64 = 1024;
85
86/// Options that shape how a user-test suite is discovered and executed.
87///
88/// Held separately from the positional path so call sites (one-shot run,
89/// `--watch`, persona doctor) can share the same scheduler without
90/// keyword-argument explosion at the call sites.
91#[derive(Clone, Default)]
92pub struct RunOptions {
93    pub filter: Option<String>,
94    pub timeout_ms: u64,
95    /// Optional hard budget for a passing test's total wall-clock duration.
96    /// Exceeding it converts the result to a failure without changing the
97    /// actual per-test timeout behavior.
98    pub max_test_ms: Option<u64>,
99    /// Optional hard budget for a passing test's `vm.execute` phase. This
100    /// catches tests whose assertions accidentally drive full agent loops or
101    /// other slow runtime behavior while ignoring setup/compile cold-start.
102    pub max_execute_ms: Option<u64>,
103    /// When false, the scheduler runs with a single worker, preserving the
104    /// historical "everything sequential" semantics that `harn test`
105    /// defaulted to before `--parallel` was introduced.
106    pub parallel: bool,
107    /// Stop claiming new cases after the first discovery or execution failure.
108    /// Cases already running in parallel finish and retain their results.
109    pub fail_fast: bool,
110    /// Explicit worker limit (`-j`/`--jobs`). `None` defaults to the
111    /// available parallelism, capped by a small constant when running in
112    /// parallel mode. Ignored when `parallel = false`.
113    pub jobs: Option<usize>,
114    /// Optional 1-based shard selection for CI matrix fan-out. Sharding
115    /// happens after discovery/filtering and before execution.
116    pub shard: Option<TestShard>,
117    pub cli_skill_dirs: Vec<PathBuf>,
118    /// Optional progress callback. When set, the runner emits events as
119    /// the suite progresses; consumers (CLI, dev mode) render output.
120    pub progress: Option<TestRunProgress>,
121    /// Emit per-test phase timings (setup / compile / execute /
122    /// teardown) to stderr. Also honored via `HARN_TEST_DIAGNOSE=1` so
123    /// users can flip the flag without restarting their shell.
124    pub diagnose: bool,
125}
126
127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
128pub struct TestShard {
129    index: usize,
130    total: usize,
131}
132
133impl TestShard {
134    pub fn new(index: usize, total: usize) -> Result<Self, String> {
135        if total == 0 {
136            return Err("test shard total must be at least 1".to_string());
137        }
138        if index == 0 {
139            return Err("test shard index must be at least 1".to_string());
140        }
141        if index > total {
142            return Err(format!(
143                "test shard index {index} exceeds shard total {total}"
144            ));
145        }
146        Ok(Self { index, total })
147    }
148
149    pub fn index(self) -> usize {
150        self.index
151    }
152
153    pub fn total(self) -> usize {
154        self.total
155    }
156}
157
158impl RunOptions {
159    pub fn new(timeout_ms: u64) -> Self {
160        Self {
161            timeout_ms,
162            ..Default::default()
163        }
164    }
165}
166
167/// A single executable test discovered during scan. Workers compile and
168/// run each case in isolation; the parsed program is shared by `Arc` so
169/// large suites parse exactly once.
170#[derive(Clone)]
171struct TestCase {
172    file: PathBuf,
173    name: String,
174    pipeline_name: String,
175    source: Arc<String>,
176    program: Arc<Vec<SNode>>,
177    /// Public enum names imported by this file, computed once during
178    /// discovery and shared by all parameterized cases from the file.
179    imported_enum_candidates: Arc<Vec<String>>,
180    /// Optional serial group — tests with the same group never run
181    /// concurrently with each other, even if workers are idle. Used for
182    /// shared fixtures.
183    serial_group: Option<String>,
184    /// Number of workers this test reserves while running. Capped at the
185    /// pool size during discovery so heavy tests still get scheduled.
186    weight: usize,
187    /// Explicit values supplied by one `@test(cases: [...])` row.
188    args: Vec<VmValue>,
189    /// Optional reusable setup selected by `@test(fixture: name)`.
190    fixture: Option<TestFixture>,
191    /// File-scoped fixture result cloned through Harn's isolate-safe COW
192    /// contract before this case enters its fresh VM.
193    file_fixture_value: Option<IsolateValue>,
194}
195
196fn canonicalize_existing_path(path: &Path) -> PathBuf {
197    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
198}
199
200fn test_execution_cwd() -> PathBuf {
201    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
202}
203
204fn emit_progress(progress: &Option<TestRunProgress>, event: TestRunEvent) {
205    if let Some(callback) = progress {
206        callback(event);
207    }
208}
209
210fn should_warn_large_sequential_suite(total_tests: usize, total_files: usize) -> bool {
211    total_tests >= LARGE_SEQUENTIAL_TEST_THRESHOLD || total_files >= LARGE_SEQUENTIAL_FILE_THRESHOLD
212}
213
214/// Discover and run tests in a file or directory.
215pub async fn run_tests(
216    path: &Path,
217    filter: Option<&str>,
218    timeout_ms: u64,
219    parallel: bool,
220    cli_skill_dirs: &[PathBuf],
221) -> TestSummary {
222    let options = RunOptions {
223        filter: filter.map(str::to_owned),
224        timeout_ms,
225        max_test_ms: test_budget_ms_via_env(HARN_TEST_MAX_MS_ENV),
226        max_execute_ms: test_budget_ms_via_env(HARN_TEST_MAX_EXECUTE_MS_ENV),
227        parallel,
228        fail_fast: false,
229        jobs: None,
230        shard: None,
231        cli_skill_dirs: cli_skill_dirs.to_vec(),
232        progress: None,
233        diagnose: diagnose_enabled_via_env(),
234    };
235    run_tests_with_options(path, &options).await
236}
237
238/// Backwards-compatible progress-emitting entry point.
239pub async fn run_tests_with_progress(
240    path: &Path,
241    filter: Option<&str>,
242    timeout_ms: u64,
243    parallel: bool,
244    cli_skill_dirs: &[PathBuf],
245    progress: Option<TestRunProgress>,
246) -> TestSummary {
247    let options = RunOptions {
248        filter: filter.map(str::to_owned),
249        timeout_ms,
250        max_test_ms: test_budget_ms_via_env(HARN_TEST_MAX_MS_ENV),
251        max_execute_ms: test_budget_ms_via_env(HARN_TEST_MAX_EXECUTE_MS_ENV),
252        parallel,
253        fail_fast: false,
254        jobs: None,
255        shard: None,
256        cli_skill_dirs: cli_skill_dirs.to_vec(),
257        progress,
258        diagnose: diagnose_enabled_via_env(),
259    };
260    run_tests_with_options(path, &options).await
261}
262
263fn diagnose_enabled_via_env() -> bool {
264    let Ok(raw) = std::env::var("HARN_TEST_DIAGNOSE") else {
265        return false;
266    };
267    matches!(
268        raw.to_ascii_lowercase().as_str(),
269        "1" | "true" | "yes" | "on"
270    )
271}
272
273fn test_budget_ms_via_env(name: &str) -> Option<u64> {
274    std::env::var(name)
275        .ok()
276        .and_then(|raw| raw.trim().parse::<u64>().ok())
277        .filter(|&value| value >= 1)
278}
279
280/// Run tests with full control over scheduling, worker count, and
281/// progress reporting. Workers and scheduling mode are reported via
282/// `TestRunEvent::SuiteDiscovered` so consumers can render their own
283/// banner instead of the runner printing to stdout directly.
284pub async fn run_tests_with_options(path: &Path, options: &RunOptions) -> TestSummary {
285    run_tests_with_session(path, options, &TestRunSession::default()).await
286}
287
288/// Run tests while retaining immutable prepared-module artifacts in `session`.
289///
290/// Callers that execute only once should use [`run_tests_with_options`]. Watch
291/// mode and long-lived hosts should retain one session for their desired cache
292/// lifetime and inspect [`TestRunSession::stats`] for reuse receipts.
293pub fn run_tests_with_session<'a>(
294    path: &'a Path,
295    options: &'a RunOptions,
296    session: &'a TestRunSession,
297) -> Pin<Box<dyn Future<Output = TestSummary> + 'a>> {
298    run_tests_with_session_and_operator_grant(path, options, session, None)
299}
300
301/// Run tests with an explicit operator grant that follows every worker.
302///
303/// `harn test --parallel` uses dedicated OS threads, so a thread-local grant
304/// installed by the CLI must be transported through this runner explicitly.
305pub(crate) fn run_tests_with_session_and_operator_grant<'a>(
306    path: &'a Path,
307    options: &'a RunOptions,
308    session: &'a TestRunSession,
309    operator_approval_grant: Option<&'a harn_vm::orchestration::OperatorApprovalGrant>,
310) -> Pin<Box<dyn Future<Output = TestSummary> + 'a>> {
311    Box::pin(run_tests_with_session_impl(
312        path,
313        options,
314        session,
315        operator_approval_grant,
316    ))
317}
318
319async fn run_tests_with_session_impl(
320    path: &Path,
321    options: &RunOptions,
322    session: &TestRunSession,
323    operator_approval_grant: Option<&harn_vm::orchestration::OperatorApprovalGrant>,
324) -> TestSummary {
325    // Default LLM provider to "mock" in test mode unless caller overrides.
326    let _default_llm_provider = ScopedEnvVar::set_if_unset("HARN_LLM_PROVIDER", "mock");
327    let _disable_llm_calls = ScopedEnvVar::set(harn_vm::llm::LLM_CALLS_DISABLED_ENV, "1");
328
329    let start = Instant::now();
330
331    let collection_start = Instant::now();
332    let canonical_target = canonicalize_existing_path(path);
333    let files = if canonical_target.is_dir() {
334        discover_test_files(&canonical_target)
335    } else {
336        vec![canonical_target.clone()]
337    };
338
339    let workers = resolve_workers(options);
340    let timings_path = timings_cache_path(&canonical_target);
341    let timings = timings_path
342        .as_deref()
343        .map(load_timings_cache)
344        .unwrap_or_default();
345
346    let mut discovery = discover_test_cases(&files, options.filter.as_deref(), workers);
347    if let Some(shard) = options.shard {
348        discovery.cases = select_shard_cases(discovery.cases, &timings, shard);
349        if shard.index() > 1 {
350            discovery.discovery_errors.clear();
351        }
352    }
353    let skill_contexts = PreparedSkillContexts::prepare(&discovery.cases, &options.cli_skill_dirs);
354    let collection_ms = collection_start.elapsed().as_millis() as u64;
355    let selected_files_with_tests = if options.shard.is_some() {
356        count_files_with_cases(&discovery.cases)
357    } else {
358        discovery.files_with_tests
359    };
360
361    emit_progress(
362        &options.progress,
363        TestRunEvent::SuiteDiscovered {
364            total_tests: discovery.cases.len(),
365            total_files: selected_files_with_tests,
366            parallel: options.parallel,
367            workers,
368        },
369    );
370    if workers == 1
371        && should_warn_large_sequential_suite(discovery.cases.len(), selected_files_with_tests)
372    {
373        emit_progress(
374            &options.progress,
375            TestRunEvent::LargeSequentialSuite {
376                total_tests: discovery.cases.len(),
377                total_files: selected_files_with_tests,
378            },
379        );
380    }
381
382    let mut cases = discovery.cases;
383    sort_cases_longest_first(&mut cases, &timings);
384    let module_preparation = session.prepare_import_graph(&case_files(&cases));
385
386    let mut all_results = discovery.discovery_errors;
387    let total_tests = cases.len();
388    if !options.fail_fast || all_results.is_empty() {
389        let prepared = prepare_file_fixtures(
390            cases,
391            options,
392            session,
393            &skill_contexts,
394            operator_approval_grant,
395        )
396        .await;
397        cases = prepared.cases;
398        all_results.extend(prepared.failures);
399    } else {
400        cases.clear();
401    }
402    let execution = if !options.fail_fast || all_results.is_empty() {
403        execute_cases(
404            cases,
405            workers,
406            options,
407            total_tests,
408            session,
409            skill_contexts,
410            operator_approval_grant,
411        )
412        .await
413    } else {
414        CaseExecutionResults::default()
415    };
416
417    let timing = DurationSummary::from_samples(
418        &execution
419            .cases
420            .iter()
421            .map(|result| result.duration_ms)
422            .collect::<Vec<_>>(),
423    );
424    if let Some(path) = timings_path.as_deref() {
425        update_timings_cache(path, timings, &execution.cases);
426    }
427    all_results.extend(execution.cases);
428    all_results.extend(execution.infrastructure_errors);
429    let total = all_results.len();
430    let passed = all_results.iter().filter(|result| result.passed).count();
431    let failed = total - passed;
432    let aggregate = AggregateTimings::from_results(collection_ms, module_preparation, &all_results);
433
434    TestSummary {
435        results: all_results,
436        passed,
437        failed,
438        total,
439        duration_ms: start.elapsed().as_millis() as u64,
440        timing,
441        aggregate,
442    }
443}
444
445/// Backwards-compatible single-file API used by `harn dev`.
446///
447/// Runs every test in one file on the current thread. The new scheduler
448/// uses per-test worker threads, but `harn dev` re-runs a single module
449/// in the foreground after each rebuild — the queueing machinery would
450/// add latency without parallelism to gain back, so we keep this path
451/// minimal.
452pub async fn run_test_file(
453    path: &Path,
454    filter: Option<&str>,
455    timeout_ms: u64,
456    execution_cwd: Option<&Path>,
457    cli_skill_dirs: &[PathBuf],
458) -> Result<Vec<TestResult>, String> {
459    run_test_file_with_session(
460        path,
461        filter,
462        timeout_ms,
463        execution_cwd,
464        cli_skill_dirs,
465        &TestRunSession::default(),
466    )
467    .await
468}
469
470/// Single-file test API that retains prepared artifacts across invocations.
471pub fn run_test_file_with_session<'a>(
472    path: &'a Path,
473    filter: Option<&'a str>,
474    timeout_ms: u64,
475    execution_cwd: Option<&'a Path>,
476    cli_skill_dirs: &'a [PathBuf],
477    session: &'a TestRunSession,
478) -> Pin<Box<dyn Future<Output = Result<Vec<TestResult>, String>> + 'a>> {
479    Box::pin(run_test_file_with_session_impl(
480        path,
481        filter,
482        timeout_ms,
483        execution_cwd,
484        cli_skill_dirs,
485        session,
486    ))
487}
488
489async fn run_test_file_with_session_impl(
490    path: &Path,
491    filter: Option<&str>,
492    timeout_ms: u64,
493    execution_cwd: Option<&Path>,
494    cli_skill_dirs: &[PathBuf],
495    session: &TestRunSession,
496) -> Result<Vec<TestResult>, String> {
497    let source =
498        fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
499    let program = parse_program(&source)?;
500    let source = Arc::new(source);
501    let program = Arc::new(program);
502
503    let mut cases = extract_cases_from_program(path, &source, &program, filter, usize::MAX)?;
504    seed_imported_enum_candidates(path, &source, &mut cases);
505    let skill_contexts = PreparedSkillContexts::prepare(&cases, cli_skill_dirs);
506    let _module_preparation = session.prepare_import_graph(&case_files(&cases));
507
508    let mut results = Vec::with_capacity(cases.len());
509    let execution_cwd = execution_cwd
510        .map(Path::to_path_buf)
511        .unwrap_or_else(test_execution_cwd);
512    let prepared_module_cache = session.prepared_module_cache(0);
513    let fixture_options = RunOptions {
514        timeout_ms,
515        ..RunOptions::default()
516    };
517    let prepared =
518        prepare_file_fixtures(cases, &fixture_options, session, &skill_contexts, None).await;
519    results.extend(prepared.failures);
520    for case in prepared.cases {
521        let loaded_skills = skill_contexts.for_case(&case);
522        results.push(
523            execute_case(
524                &case,
525                &execution_cwd,
526                timeout_ms,
527                loaded_skills,
528                &prepared_module_cache,
529                session.stdio_available(),
530                None,
531            )
532            .await,
533        );
534    }
535    Ok(results)
536}
537
538fn resolve_workers(options: &RunOptions) -> usize {
539    if !options.parallel {
540        return 1;
541    }
542    if let Some(jobs) = options.jobs {
543        return jobs.max(1);
544    }
545    if let Ok(raw) = std::env::var(HARN_TEST_JOBS_ENV) {
546        if let Ok(parsed) = raw.trim().parse::<usize>() {
547            if parsed >= 1 {
548                return parsed;
549            }
550        }
551    }
552    let detected = thread::available_parallelism()
553        .map(|n| n.get())
554        .unwrap_or(1);
555    let core_cap = detected.clamp(1, DEFAULT_PARALLEL_JOBS_CAP);
556    apply_memory_cap(core_cap)
557}
558
559/// Lower `core_cap` to what currently-available system memory can hold, so an
560/// auto-sized parallel suite backs off on a loaded or small host instead of
561/// overcommitting RAM. Returns `core_cap` unchanged when memory is plentiful
562/// or cannot be measured. Emits a one-line notice when the cap bites so CI
563/// logs explain the reduced parallelism.
564fn apply_memory_cap(core_cap: usize) -> usize {
565    let Some(available_mb) = available_memory_mb() else {
566        return core_cap;
567    };
568    let budget = per_worker_memory_mb();
569    let mem_cap = memory_worker_cap(available_mb, budget, RESERVED_SYSTEM_MEMORY_MB);
570    if mem_cap < core_cap {
571        eprintln!(
572            "harn test: capping workers {core_cap} -> {mem_cap} \
573             (~{available_mb} MiB available, {budget} MiB/worker; \
574             override with --jobs / HARN_TEST_JOBS)"
575        );
576        return mem_cap;
577    }
578    core_cap
579}
580
581/// Pure worker-count-from-memory math, factored out so it is unit-testable
582/// without touching the host. Always yields at least one worker.
583fn memory_worker_cap(available_mb: u64, per_worker_mb: u64, reserved_mb: u64) -> usize {
584    let usable = available_mb.saturating_sub(reserved_mb);
585    let per_worker = per_worker_mb.max(1);
586    ((usable / per_worker).max(1)) as usize
587}
588
589/// Per-worker memory budget, honoring the `HARN_TEST_WORKER_MEMORY_MB`
590/// override (values `>= 1`), else [`DEFAULT_WORKER_MEMORY_MB`].
591fn per_worker_memory_mb() -> u64 {
592    std::env::var(HARN_TEST_WORKER_MEMORY_MB_ENV)
593        .ok()
594        .and_then(|raw| raw.trim().parse::<u64>().ok())
595        .filter(|&n| n >= 1)
596        .unwrap_or(DEFAULT_WORKER_MEMORY_MB)
597}
598
599/// Best-effort "memory available for new work" in MiB: the lesser of the
600/// host's available memory and (on Linux) this process's cgroup-v2 headroom.
601///
602/// Host memory comes from `sysinfo`, so it is correct on Linux, macOS, and
603/// Windows. The cgroup min means a container or a systemd-sliced CI runner
604/// sizes to its *slice* rather than the whole host — the key to stopping two
605/// runner agents on one box from each sizing to ~100% and collectively
606/// overcommitting RAM (the "thundering herd" behind the self-hosted
607/// runner-loss cancellations). Returns `None` when nothing can be measured,
608/// leaving the core-based cap in force.
609fn available_memory_mb() -> Option<u64> {
610    let mut sys = sysinfo::System::new();
611    sys.refresh_memory();
612    let host_mb = match sys.available_memory() {
613        0 => None, // unsupported / detection failed — don't over-throttle
614        bytes => Some(bytes / (1024 * 1024)),
615    };
616    match (host_mb, cgroup_v2_headroom_mb()) {
617        (Some(h), Some(c)) => Some(h.min(c)),
618        (Some(h), None) => Some(h),
619        (None, c) => c,
620    }
621}
622
623/// cgroup-v2 memory headroom (MiB) for this process's own cgroup, or `None`
624/// when not on cgroup v2, no limit is set, or the files cannot be read.
625#[cfg(target_os = "linux")]
626fn cgroup_v2_headroom_mb() -> Option<u64> {
627    let dir = own_cgroup_v2_dir()?;
628    let max_raw = fs::read_to_string(dir.join("memory.max")).ok()?;
629    let current_raw = fs::read_to_string(dir.join("memory.current")).ok()?;
630    cgroup_headroom_mb(&max_raw, &current_raw)
631}
632
633#[cfg(not(target_os = "linux"))]
634fn cgroup_v2_headroom_mb() -> Option<u64> {
635    None
636}
637
638/// Resolve this process's own cgroup-v2 directory under `/sys/fs/cgroup` from
639/// the unified-hierarchy line (`0::<path>`) in `/proc/self/cgroup`. A limit
640/// set directly on a systemd service slice or on a container's namespaced
641/// root lives here; ancestor-only limits are not chased (the host min still
642/// backstops those). `None` on cgroup v1 / hybrid (no `0::` line).
643#[cfg(target_os = "linux")]
644fn own_cgroup_v2_dir() -> Option<PathBuf> {
645    let content = fs::read_to_string("/proc/self/cgroup").ok()?;
646    let rel = content
647        .lines()
648        .find_map(|line| line.strip_prefix("0::"))?
649        .trim();
650    let rel = rel.strip_prefix('/').unwrap_or(rel);
651    Some(Path::new("/sys/fs/cgroup").join(rel))
652}
653
654/// Pure headroom math from raw `memory.max` / `memory.current` file contents
655/// (both bytes; `memory.max` may be the literal `"max"` sentinel = unlimited).
656/// `memory.current` counts reclaimable page cache, so the result is a
657/// conservative (under-)estimate of true headroom — the safe direction for
658/// OOM avoidance.
659#[cfg(any(target_os = "linux", test))]
660fn cgroup_headroom_mb(memory_max: &str, memory_current: &str) -> Option<u64> {
661    let max = memory_max.trim();
662    if max == "max" {
663        return None;
664    }
665    let max: u64 = max.parse().ok()?;
666    let current: u64 = memory_current.trim().parse().ok()?;
667    Some(max.saturating_sub(current) / (1024 * 1024))
668}
669
670struct Discovery {
671    cases: Vec<TestCase>,
672    files_with_tests: usize,
673    discovery_errors: Vec<TestResult>,
674}
675
676fn discover_test_cases(files: &[PathBuf], filter: Option<&str>, workers: usize) -> Discovery {
677    let mut cases = Vec::new();
678    let mut files_with_tests = 0usize;
679    let mut discovery_errors = Vec::new();
680
681    for file in files {
682        let source = match fs::read_to_string(file) {
683            Ok(s) => s,
684            Err(e) => {
685                discovery_errors.push(TestResult {
686                    name: "<file error>".to_string(),
687                    file: file.display().to_string(),
688                    passed: false,
689                    error: Some(format!("Failed to read {}: {e}", file.display())),
690                    captured_output: None,
691                    timeout: None,
692                    duration_ms: 0,
693                    phases: None,
694                });
695                continue;
696            }
697        };
698
699        let program = match parse_program(&source) {
700            Ok(p) => p,
701            Err(e) => {
702                discovery_errors.push(TestResult {
703                    name: "<file error>".to_string(),
704                    file: file.display().to_string(),
705                    passed: false,
706                    error: Some(e),
707                    captured_output: None,
708                    timeout: None,
709                    duration_ms: 0,
710                    phases: None,
711                });
712                continue;
713            }
714        };
715
716        let source = Arc::new(source);
717        let program = Arc::new(program);
718        match extract_cases_from_program(file, &source, &program, filter, workers) {
719            Ok(mut file_cases) => {
720                if !file_cases.is_empty() {
721                    seed_imported_enum_candidates(file, &source, &mut file_cases);
722                    files_with_tests += 1;
723                    cases.extend(file_cases);
724                }
725            }
726            Err(error) => discovery_errors.push(TestResult {
727                name: "<file error>".to_string(),
728                file: file.display().to_string(),
729                passed: false,
730                error: Some(error),
731                captured_output: None,
732                timeout: None,
733                duration_ms: 0,
734                phases: None,
735            }),
736        }
737    }
738
739    Discovery {
740        cases,
741        files_with_tests,
742        discovery_errors,
743    }
744}
745
746fn sort_cases_longest_first(cases: &mut [TestCase], timings: &BTreeMap<String, u64>) {
747    // Sort ascending so the slowest tests sit at the tail and get popped
748    // first by workers. New (unmeasured) tests share the bottom of the
749    // queue alongside the fastest known ones — they'll appear in stable
750    // file/name order, and once they get their first timing they'll
751    // float up to where they belong.
752    cases.sort_by(|a, b| {
753        let key_a = timings_key(&a.file, &a.name);
754        let key_b = timings_key(&b.file, &b.name);
755        let dur_a = timings.get(&key_a).copied().unwrap_or(0);
756        let dur_b = timings.get(&key_b).copied().unwrap_or(0);
757        dur_a
758            .cmp(&dur_b)
759            .then_with(|| a.file.cmp(&b.file))
760            .then_with(|| a.name.cmp(&b.name))
761    });
762}
763
764fn select_shard_cases(
765    cases: Vec<TestCase>,
766    timings: &BTreeMap<String, u64>,
767    shard: TestShard,
768) -> Vec<TestCase> {
769    if shard.total() <= 1 {
770        return cases;
771    }
772
773    let mut ranked = cases.into_iter().collect::<Vec<_>>();
774    ranked.sort_by(|a, b| {
775        estimated_case_cost_ms(b, timings)
776            .cmp(&estimated_case_cost_ms(a, timings))
777            .then_with(|| a.file.cmp(&b.file))
778            .then_with(|| a.name.cmp(&b.name))
779    });
780
781    let mut buckets = (0..shard.total()).map(|_| Vec::new()).collect::<Vec<_>>();
782    let mut costs = vec![0u64; shard.total()];
783    let mut counts = vec![0usize; shard.total()];
784
785    for case in ranked {
786        let bucket_index = (0..shard.total())
787            .min_by_key(|&index| (costs[index], counts[index], index))
788            .unwrap_or(0);
789        costs[bucket_index] =
790            costs[bucket_index].saturating_add(estimated_case_cost_ms(&case, timings));
791        counts[bucket_index] += 1;
792        buckets[bucket_index].push(case);
793    }
794
795    buckets.swap_remove(shard.index() - 1)
796}
797
798fn estimated_case_cost_ms(case: &TestCase, timings: &BTreeMap<String, u64>) -> u64 {
799    timings
800        .get(&timings_key(&case.file, &case.name))
801        .copied()
802        .unwrap_or(case.weight as u64)
803        .max(1)
804}
805
806fn count_files_with_cases(cases: &[TestCase]) -> usize {
807    let mut files = HashSet::new();
808    for case in cases {
809        files.insert(case.file.as_path());
810    }
811    files.len()
812}
813
814fn case_files(cases: &[TestCase]) -> Vec<PathBuf> {
815    cases
816        .iter()
817        .map(|case| case.file.clone())
818        .collect::<std::collections::BTreeSet<_>>()
819        .into_iter()
820        .collect()
821}
822
823fn timings_key(file: &Path, name: &str) -> String {
824    format!("{}::{}", file.display(), name)
825}
826
827fn timings_cache_path(target: &Path) -> Option<PathBuf> {
828    // Anchor the cache at the project root if discoverable, otherwise at
829    // the directory the suite was launched from. The cache is shared
830    // across runs in the same workspace, so a per-suite cache would
831    // fragment timings whenever a user runs a subset.
832    let probe_root = if target.is_dir() {
833        target.to_path_buf()
834    } else {
835        target.parent()?.to_path_buf()
836    };
837    let root = harn_vm::stdlib::process::find_project_root(&probe_root)
838        .unwrap_or_else(|| probe_root.clone());
839    Some(root.join(TIMINGS_CACHE_RELATIVE_PATH))
840}
841
842fn load_timings_cache(path: &Path) -> BTreeMap<String, u64> {
843    let Ok(contents) = fs::read_to_string(path) else {
844        return BTreeMap::new();
845    };
846    serde_json::from_str::<BTreeMap<String, u64>>(&contents).unwrap_or_default()
847}
848
849fn update_timings_cache(path: &Path, mut existing: BTreeMap<String, u64>, results: &[TestResult]) {
850    for result in results {
851        existing.insert(
852            timings_key(Path::new(&result.file), &result.name),
853            result.duration_ms,
854        );
855    }
856    if let Some(parent) = path.parent() {
857        let _ = fs::create_dir_all(parent);
858    }
859    if let Ok(serialized) = serde_json::to_string(&existing) {
860        let _ = fs::write(path, serialized);
861    }
862}
863
864#[derive(Default)]
865struct CaseExecutionResults {
866    cases: Vec<TestResult>,
867    infrastructure_errors: Vec<TestResult>,
868}
869
870struct PreparedFixtureCases {
871    cases: Vec<TestCase>,
872    failures: Vec<TestResult>,
873}
874
875async fn prepare_file_fixtures(
876    cases: Vec<TestCase>,
877    options: &RunOptions,
878    session: &TestRunSession,
879    skill_contexts: &PreparedSkillContexts,
880    operator_approval_grant: Option<&harn_vm::orchestration::OperatorApprovalGrant>,
881) -> PreparedFixtureCases {
882    let mut values: BTreeMap<(PathBuf, String), Result<IsolateValue, TestResult>> = BTreeMap::new();
883    let mut prepared = Vec::with_capacity(cases.len());
884    let mut failures = Vec::new();
885    let prepared_module_cache = session.prepared_module_cache(0);
886
887    for mut case in cases {
888        let Some(fixture) = case
889            .fixture
890            .as_ref()
891            .filter(|fixture| fixture.scope == FixtureScope::File)
892            .cloned()
893        else {
894            prepared.push(case);
895            continue;
896        };
897        let key = (case.file.clone(), fixture.name.clone());
898        if !values.contains_key(&key) {
899            let cwd = case_execution_cwd(&case);
900            let value = execute_file_fixture(
901                &case,
902                &fixture,
903                &cwd,
904                options.timeout_ms,
905                skill_contexts.for_case(&case),
906                &prepared_module_cache,
907                session.stdio_available(),
908                operator_approval_grant,
909            )
910            .await;
911            if let Err(failure) = &value {
912                failures.push(failure.clone());
913            }
914            values.insert(key.clone(), value);
915        }
916        match values.get(&key).expect("fixture result inserted above") {
917            Ok(value) => {
918                case.file_fixture_value = Some(value.clone());
919                prepared.push(case);
920            }
921            Err(_) if options.fail_fast => {
922                prepared.clear();
923                break;
924            }
925            Err(_) => {}
926        }
927    }
928
929    PreparedFixtureCases {
930        cases: prepared,
931        failures,
932    }
933}
934
935async fn execute_cases(
936    cases: Vec<TestCase>,
937    workers: usize,
938    options: &RunOptions,
939    total_tests: usize,
940    session: &TestRunSession,
941    skill_contexts: PreparedSkillContexts,
942    operator_approval_grant: Option<&harn_vm::orchestration::OperatorApprovalGrant>,
943) -> CaseExecutionResults {
944    if cases.is_empty() {
945        return CaseExecutionResults::default();
946    }
947    let completed = Arc::new(Mutex::new(0usize));
948    if workers <= 1 {
949        let prepared_module_cache = session.prepared_module_cache(0);
950        let mut results = Vec::with_capacity(cases.len());
951        for case in cases {
952            let loaded_skills = skill_contexts.for_case(&case);
953            let cwd = case_execution_cwd(&case);
954            let test_index = next_test_index(&completed);
955            emit_progress(
956                &options.progress,
957                TestRunEvent::TestStarted {
958                    name: case.name.clone(),
959                    file: case.file.display().to_string(),
960                    test_index,
961                    total_tests,
962                },
963            );
964            let result = execute_case(
965                &case,
966                &cwd,
967                options.timeout_ms,
968                loaded_skills,
969                &prepared_module_cache,
970                session.stdio_available(),
971                operator_approval_grant,
972            )
973            .await;
974            let result = enforce_case_budgets(result, options.max_test_ms, options.max_execute_ms);
975            if options.diagnose {
976                result.emit_diagnose();
977            }
978            emit_progress(
979                &options.progress,
980                TestRunEvent::TestFinished(result.clone()),
981            );
982            results.push(result);
983            if options.fail_fast && !results.last().is_some_and(|result| result.passed) {
984                break;
985            }
986        }
987        return CaseExecutionResults {
988            cases: results,
989            infrastructure_errors: Vec::new(),
990        };
991    }
992
993    let queue = Arc::new(Mutex::new(cases));
994    let skill_contexts = Arc::new(skill_contexts);
995    let gate = Arc::new(ResourceGate::new(workers));
996    let results: Arc<Mutex<Vec<TestResult>>> = Arc::new(Mutex::new(Vec::new()));
997    let infrastructure_errors: Arc<Mutex<Vec<TestResult>>> = Arc::new(Mutex::new(Vec::new()));
998    let cancelled = Arc::new(AtomicBool::new(false));
999
1000    let mut handles = Vec::with_capacity(workers);
1001    for worker_idx in 0..workers {
1002        let queue = Arc::clone(&queue);
1003        let skill_contexts = Arc::clone(&skill_contexts);
1004        let gate = Arc::clone(&gate);
1005        let results = Arc::clone(&results);
1006        let infrastructure_errors = Arc::clone(&infrastructure_errors);
1007        let completed = Arc::clone(&completed);
1008        let timeout_ms = options.timeout_ms;
1009        let max_test_ms = options.max_test_ms;
1010        let max_execute_ms = options.max_execute_ms;
1011        let progress = options.progress.clone();
1012        let diagnose = options.diagnose;
1013        let fail_fast = options.fail_fast;
1014        let cancelled = Arc::clone(&cancelled);
1015        let prepared_module_cache = session.prepared_module_cache(worker_idx);
1016        let stdio_available = session.stdio_available();
1017        let operator_approval_grant = operator_approval_grant.cloned();
1018        let handle = thread::Builder::new()
1019            .name(format!("harn-test-worker-{worker_idx}"))
1020            .stack_size(CLI_RUNTIME_STACK_SIZE)
1021            .spawn(move || {
1022                let runtime = match tokio::runtime::Builder::new_current_thread()
1023                    .enable_all()
1024                    .build()
1025                {
1026                    Ok(rt) => rt,
1027                    Err(error) => {
1028                        infrastructure_errors.lock().unwrap().push(TestResult {
1029                            name: "<worker error>".to_string(),
1030                            file: String::new(),
1031                            passed: false,
1032                            error: Some(format!("failed to start test runtime: {error}")),
1033                            captured_output: None,
1034                            timeout: None,
1035                            duration_ms: 0,
1036                            phases: None,
1037                        });
1038                        return;
1039                    }
1040                };
1041                // Cases are sorted ascending by historical duration; popping
1042                // from the tail gives this worker the slowest unclaimed
1043                // test, which front-loads long poles and prevents workers
1044                // from stranding on quick tests at the end of the run.
1045                loop {
1046                    let case = claim_next_case(&queue, &cancelled, fail_fast);
1047                    let Some(case) = case else { break };
1048                    let _guard = gate.acquire(case.weight, case.serial_group.as_deref());
1049                    // A worker may have claimed this case before another
1050                    // worker failed, then waited behind a heavy/serial gate.
1051                    // Recheck at the execution barrier so queued work is not
1052                    // mistaken for already-running work under fail-fast.
1053                    if fail_fast && cancelled.load(Ordering::Acquire) {
1054                        break;
1055                    }
1056                    let cwd = case_execution_cwd(&case);
1057                    let loaded_skills = skill_contexts.for_case(&case);
1058                    let test_index = next_test_index(&completed);
1059                    emit_progress(
1060                        &progress,
1061                        TestRunEvent::TestStarted {
1062                            name: case.name.clone(),
1063                            file: case.file.display().to_string(),
1064                            test_index,
1065                            total_tests,
1066                        },
1067                    );
1068                    let result = runtime.block_on(execute_case(
1069                        &case,
1070                        &cwd,
1071                        timeout_ms,
1072                        loaded_skills,
1073                        &prepared_module_cache,
1074                        stdio_available,
1075                        operator_approval_grant.as_ref(),
1076                    ));
1077                    let result = enforce_case_budgets(result, max_test_ms, max_execute_ms);
1078                    if fail_fast && !result.passed {
1079                        cancelled.store(true, Ordering::Release);
1080                    }
1081                    if diagnose {
1082                        result.emit_diagnose();
1083                    }
1084                    emit_progress(&progress, TestRunEvent::TestFinished(result.clone()));
1085                    results.lock().unwrap().push(result);
1086                }
1087            })
1088            .expect("spawning a harn-test worker thread should succeed");
1089        handles.push(handle);
1090    }
1091
1092    for handle in handles {
1093        let _ = handle.join();
1094    }
1095
1096    // All workers have joined, so this Arc holds the only remaining
1097    // reference. The lock-and-clone fallback survives the unlikely case
1098    // where a panic-unwind kept an extra reference alive.
1099    let cases = Arc::try_unwrap(results)
1100        .map(|m| m.into_inner().unwrap_or_default())
1101        .unwrap_or_else(|arc| arc.lock().unwrap().clone());
1102    let infrastructure_errors = Arc::try_unwrap(infrastructure_errors)
1103        .map(|mutex| mutex.into_inner().unwrap_or_default())
1104        .unwrap_or_else(|arc| arc.lock().unwrap().clone());
1105    CaseExecutionResults {
1106        cases,
1107        infrastructure_errors,
1108    }
1109}
1110
1111fn claim_next_case(
1112    queue: &Mutex<Vec<TestCase>>,
1113    cancelled: &AtomicBool,
1114    fail_fast: bool,
1115) -> Option<TestCase> {
1116    let mut queue = queue.lock().unwrap();
1117    if fail_fast && cancelled.load(Ordering::Acquire) {
1118        None
1119    } else {
1120        queue.pop()
1121    }
1122}
1123
1124fn enforce_case_budgets(
1125    mut result: TestResult,
1126    max_test_ms: Option<u64>,
1127    max_execute_ms: Option<u64>,
1128) -> TestResult {
1129    if !result.passed {
1130        return result;
1131    }
1132
1133    let phases = result
1134        .phases
1135        .expect("passed test results always carry measured phases");
1136    let mut violations = Vec::new();
1137    if let Some(max_ms) = max_test_ms {
1138        if result.duration_ms > max_ms {
1139            violations.push(format!(
1140                "exceeded test wall-clock budget: {}ms > {}ms",
1141                result.duration_ms, max_ms
1142            ));
1143        }
1144    }
1145    if let Some(max_ms) = max_execute_ms {
1146        if phases.execute_ms > max_ms {
1147            violations.push(format!(
1148                "exceeded test execute budget: {}ms > {}ms",
1149                phases.execute_ms, max_ms
1150            ));
1151        }
1152    }
1153
1154    if violations.is_empty() {
1155        return result;
1156    }
1157
1158    violations.push(format!(
1159        "phase timings: setup={}ms compile={}ms execute={}ms teardown={}ms total={}ms",
1160        phases.setup_ms,
1161        phases.compile_ms,
1162        phases.execute_ms,
1163        phases.teardown_ms,
1164        result.duration_ms
1165    ));
1166    result.passed = false;
1167    result.error = Some(violations.join("\n"));
1168    result
1169}
1170
1171fn next_test_index(counter: &Mutex<usize>) -> usize {
1172    let mut guard = counter.lock().unwrap();
1173    *guard += 1;
1174    *guard
1175}
1176
1177fn case_execution_cwd(case: &TestCase) -> PathBuf {
1178    case.file
1179        .parent()
1180        .filter(|p| !p.as_os_str().is_empty())
1181        .map(Path::to_path_buf)
1182        .unwrap_or_else(test_execution_cwd)
1183}
1184
1185/// Coordinates worker permits and serial-group exclusivity without
1186/// requiring an async lock — workers are dedicated OS threads, so a
1187/// classic Mutex+Condvar gate keeps everything off the tokio scheduler.
1188struct ResourceGate {
1189    state: Mutex<GateState>,
1190    cond: Condvar,
1191    capacity: usize,
1192}
1193
1194struct GateState {
1195    available: usize,
1196    busy_groups: HashSet<String>,
1197}
1198
1199struct GateGuard<'a> {
1200    gate: &'a ResourceGate,
1201    weight: usize,
1202    group: Option<String>,
1203}
1204
1205impl ResourceGate {
1206    fn new(capacity: usize) -> Self {
1207        Self {
1208            state: Mutex::new(GateState {
1209                available: capacity,
1210                busy_groups: HashSet::new(),
1211            }),
1212            cond: Condvar::new(),
1213            capacity,
1214        }
1215    }
1216
1217    fn acquire(&self, weight: usize, group: Option<&str>) -> GateGuard<'_> {
1218        let weight = weight.min(self.capacity).max(1);
1219        let mut state = self.state.lock().unwrap();
1220        loop {
1221            if let Some(guard) = self.try_grab_locked(&mut state, weight, group) {
1222                return guard;
1223            }
1224            state = self.cond.wait(state).unwrap();
1225        }
1226    }
1227
1228    /// Grab a permit if one is immediately available, holding the already-locked
1229    /// state. Returns `None` without blocking when the pool is exhausted or the
1230    /// group is busy. Shared by `acquire` (which retries) and `try_acquire`.
1231    fn try_grab_locked<'a>(
1232        &'a self,
1233        state: &mut GateState,
1234        weight: usize,
1235        group: Option<&str>,
1236    ) -> Option<GateGuard<'a>> {
1237        let group_free = group.is_none_or(|g| !state.busy_groups.contains(g));
1238        if state.available >= weight && group_free {
1239            state.available -= weight;
1240            if let Some(g) = group {
1241                state.busy_groups.insert(g.to_string());
1242            }
1243            return Some(GateGuard {
1244                gate: self,
1245                weight,
1246                group: group.map(str::to_owned),
1247            });
1248        }
1249        None
1250    }
1251
1252    /// Non-blocking variant of `acquire` used by tests to assert gate state
1253    /// deterministically (in-process, no threads or wall-clock sleeps).
1254    #[cfg(test)]
1255    fn try_acquire(&self, weight: usize, group: Option<&str>) -> Option<GateGuard<'_>> {
1256        let weight = weight.min(self.capacity).max(1);
1257        let mut state = self.state.lock().unwrap();
1258        self.try_grab_locked(&mut state, weight, group)
1259    }
1260}
1261
1262impl Drop for GateGuard<'_> {
1263    fn drop(&mut self) {
1264        let mut state = self.gate.state.lock().unwrap();
1265        state.available += self.weight;
1266        if let Some(group) = self.group.as_deref() {
1267            state.busy_groups.remove(group);
1268        }
1269        self.gate.cond.notify_all();
1270    }
1271}
1272
1273fn discover_test_files(dir: &Path) -> Vec<PathBuf> {
1274    let mut files = Vec::new();
1275    if let Ok(entries) = fs::read_dir(dir) {
1276        for entry in entries.flatten() {
1277            let path = entry.path();
1278            if path.is_dir() {
1279                files.extend(discover_test_files(&path));
1280            } else if path.extension().is_some_and(|e| e == "harn") {
1281                if let Ok(content) = fs::read_to_string(&path) {
1282                    if content.contains("test_") || content.contains("@test") {
1283                        files.push(canonicalize_existing_path(&path));
1284                    }
1285                }
1286            }
1287        }
1288    }
1289    files.sort();
1290    files
1291}