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
385    let mut all_results = discovery.discovery_errors;
386    let total_tests = cases.len();
387    if !options.fail_fast || all_results.is_empty() {
388        let prepared = prepare_file_fixtures(
389            cases,
390            options,
391            session,
392            &skill_contexts,
393            operator_approval_grant,
394        )
395        .await;
396        cases = prepared.cases;
397        all_results.extend(prepared.failures);
398    } else {
399        cases.clear();
400    }
401    let execution = if !options.fail_fast || all_results.is_empty() {
402        execute_cases(
403            cases,
404            workers,
405            options,
406            total_tests,
407            session,
408            skill_contexts,
409            operator_approval_grant,
410        )
411        .await
412    } else {
413        CaseExecutionResults::default()
414    };
415
416    let timing = DurationSummary::from_samples(
417        &execution
418            .cases
419            .iter()
420            .map(|result| result.duration_ms)
421            .collect::<Vec<_>>(),
422    );
423    if let Some(path) = timings_path.as_deref() {
424        update_timings_cache(path, timings, &execution.cases);
425    }
426    all_results.extend(execution.cases);
427    all_results.extend(execution.infrastructure_errors);
428    let total = all_results.len();
429    let passed = all_results.iter().filter(|result| result.passed).count();
430    let failed = total - passed;
431    let aggregate = AggregateTimings::from_results(collection_ms, &all_results);
432
433    TestSummary {
434        results: all_results,
435        passed,
436        failed,
437        total,
438        duration_ms: start.elapsed().as_millis() as u64,
439        timing,
440        aggregate,
441    }
442}
443
444/// Backwards-compatible single-file API used by `harn dev`.
445///
446/// Runs every test in one file on the current thread. The new scheduler
447/// uses per-test worker threads, but `harn dev` re-runs a single module
448/// in the foreground after each rebuild — the queueing machinery would
449/// add latency without parallelism to gain back, so we keep this path
450/// minimal.
451pub async fn run_test_file(
452    path: &Path,
453    filter: Option<&str>,
454    timeout_ms: u64,
455    execution_cwd: Option<&Path>,
456    cli_skill_dirs: &[PathBuf],
457) -> Result<Vec<TestResult>, String> {
458    run_test_file_with_session(
459        path,
460        filter,
461        timeout_ms,
462        execution_cwd,
463        cli_skill_dirs,
464        &TestRunSession::default(),
465    )
466    .await
467}
468
469/// Single-file test API that retains prepared artifacts across invocations.
470pub fn run_test_file_with_session<'a>(
471    path: &'a Path,
472    filter: Option<&'a str>,
473    timeout_ms: u64,
474    execution_cwd: Option<&'a Path>,
475    cli_skill_dirs: &'a [PathBuf],
476    session: &'a TestRunSession,
477) -> Pin<Box<dyn Future<Output = Result<Vec<TestResult>, String>> + 'a>> {
478    Box::pin(run_test_file_with_session_impl(
479        path,
480        filter,
481        timeout_ms,
482        execution_cwd,
483        cli_skill_dirs,
484        session,
485    ))
486}
487
488async fn run_test_file_with_session_impl(
489    path: &Path,
490    filter: Option<&str>,
491    timeout_ms: u64,
492    execution_cwd: Option<&Path>,
493    cli_skill_dirs: &[PathBuf],
494    session: &TestRunSession,
495) -> Result<Vec<TestResult>, String> {
496    let source =
497        fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
498    let program = parse_program(&source)?;
499    let source = Arc::new(source);
500    let program = Arc::new(program);
501
502    let mut cases = extract_cases_from_program(path, &source, &program, filter, usize::MAX)?;
503    seed_imported_enum_candidates(path, &source, &mut cases);
504    let skill_contexts = PreparedSkillContexts::prepare(&cases, cli_skill_dirs);
505
506    let mut results = Vec::with_capacity(cases.len());
507    let execution_cwd = execution_cwd
508        .map(Path::to_path_buf)
509        .unwrap_or_else(test_execution_cwd);
510    let prepared_module_cache = session.prepared_module_cache(0);
511    let fixture_options = RunOptions {
512        timeout_ms,
513        ..RunOptions::default()
514    };
515    let prepared =
516        prepare_file_fixtures(cases, &fixture_options, session, &skill_contexts, None).await;
517    results.extend(prepared.failures);
518    for case in prepared.cases {
519        let loaded_skills = skill_contexts.for_case(&case);
520        results.push(
521            execute_case(
522                &case,
523                &execution_cwd,
524                timeout_ms,
525                loaded_skills,
526                &prepared_module_cache,
527                session.stdio_available(),
528                None,
529            )
530            .await,
531        );
532    }
533    Ok(results)
534}
535
536fn resolve_workers(options: &RunOptions) -> usize {
537    if !options.parallel {
538        return 1;
539    }
540    if let Some(jobs) = options.jobs {
541        return jobs.max(1);
542    }
543    if let Ok(raw) = std::env::var(HARN_TEST_JOBS_ENV) {
544        if let Ok(parsed) = raw.trim().parse::<usize>() {
545            if parsed >= 1 {
546                return parsed;
547            }
548        }
549    }
550    let detected = thread::available_parallelism()
551        .map(|n| n.get())
552        .unwrap_or(1);
553    let core_cap = detected.clamp(1, DEFAULT_PARALLEL_JOBS_CAP);
554    apply_memory_cap(core_cap)
555}
556
557/// Lower `core_cap` to what currently-available system memory can hold, so an
558/// auto-sized parallel suite backs off on a loaded or small host instead of
559/// overcommitting RAM. Returns `core_cap` unchanged when memory is plentiful
560/// or cannot be measured. Emits a one-line notice when the cap bites so CI
561/// logs explain the reduced parallelism.
562fn apply_memory_cap(core_cap: usize) -> usize {
563    let Some(available_mb) = available_memory_mb() else {
564        return core_cap;
565    };
566    let budget = per_worker_memory_mb();
567    let mem_cap = memory_worker_cap(available_mb, budget, RESERVED_SYSTEM_MEMORY_MB);
568    if mem_cap < core_cap {
569        eprintln!(
570            "harn test: capping workers {core_cap} -> {mem_cap} \
571             (~{available_mb} MiB available, {budget} MiB/worker; \
572             override with --jobs / HARN_TEST_JOBS)"
573        );
574        return mem_cap;
575    }
576    core_cap
577}
578
579/// Pure worker-count-from-memory math, factored out so it is unit-testable
580/// without touching the host. Always yields at least one worker.
581fn memory_worker_cap(available_mb: u64, per_worker_mb: u64, reserved_mb: u64) -> usize {
582    let usable = available_mb.saturating_sub(reserved_mb);
583    let per_worker = per_worker_mb.max(1);
584    ((usable / per_worker).max(1)) as usize
585}
586
587/// Per-worker memory budget, honoring the `HARN_TEST_WORKER_MEMORY_MB`
588/// override (values `>= 1`), else [`DEFAULT_WORKER_MEMORY_MB`].
589fn per_worker_memory_mb() -> u64 {
590    std::env::var(HARN_TEST_WORKER_MEMORY_MB_ENV)
591        .ok()
592        .and_then(|raw| raw.trim().parse::<u64>().ok())
593        .filter(|&n| n >= 1)
594        .unwrap_or(DEFAULT_WORKER_MEMORY_MB)
595}
596
597/// Best-effort "memory available for new work" in MiB: the lesser of the
598/// host's available memory and (on Linux) this process's cgroup-v2 headroom.
599///
600/// Host memory comes from `sysinfo`, so it is correct on Linux, macOS, and
601/// Windows. The cgroup min means a container or a systemd-sliced CI runner
602/// sizes to its *slice* rather than the whole host — the key to stopping two
603/// runner agents on one box from each sizing to ~100% and collectively
604/// overcommitting RAM (the "thundering herd" behind the self-hosted
605/// runner-loss cancellations). Returns `None` when nothing can be measured,
606/// leaving the core-based cap in force.
607fn available_memory_mb() -> Option<u64> {
608    let mut sys = sysinfo::System::new();
609    sys.refresh_memory();
610    let host_mb = match sys.available_memory() {
611        0 => None, // unsupported / detection failed — don't over-throttle
612        bytes => Some(bytes / (1024 * 1024)),
613    };
614    match (host_mb, cgroup_v2_headroom_mb()) {
615        (Some(h), Some(c)) => Some(h.min(c)),
616        (Some(h), None) => Some(h),
617        (None, c) => c,
618    }
619}
620
621/// cgroup-v2 memory headroom (MiB) for this process's own cgroup, or `None`
622/// when not on cgroup v2, no limit is set, or the files cannot be read.
623#[cfg(target_os = "linux")]
624fn cgroup_v2_headroom_mb() -> Option<u64> {
625    let dir = own_cgroup_v2_dir()?;
626    let max_raw = fs::read_to_string(dir.join("memory.max")).ok()?;
627    let current_raw = fs::read_to_string(dir.join("memory.current")).ok()?;
628    cgroup_headroom_mb(&max_raw, &current_raw)
629}
630
631#[cfg(not(target_os = "linux"))]
632fn cgroup_v2_headroom_mb() -> Option<u64> {
633    None
634}
635
636/// Resolve this process's own cgroup-v2 directory under `/sys/fs/cgroup` from
637/// the unified-hierarchy line (`0::<path>`) in `/proc/self/cgroup`. A limit
638/// set directly on a systemd service slice or on a container's namespaced
639/// root lives here; ancestor-only limits are not chased (the host min still
640/// backstops those). `None` on cgroup v1 / hybrid (no `0::` line).
641#[cfg(target_os = "linux")]
642fn own_cgroup_v2_dir() -> Option<PathBuf> {
643    let content = fs::read_to_string("/proc/self/cgroup").ok()?;
644    let rel = content
645        .lines()
646        .find_map(|line| line.strip_prefix("0::"))?
647        .trim();
648    let rel = rel.strip_prefix('/').unwrap_or(rel);
649    Some(Path::new("/sys/fs/cgroup").join(rel))
650}
651
652/// Pure headroom math from raw `memory.max` / `memory.current` file contents
653/// (both bytes; `memory.max` may be the literal `"max"` sentinel = unlimited).
654/// `memory.current` counts reclaimable page cache, so the result is a
655/// conservative (under-)estimate of true headroom — the safe direction for
656/// OOM avoidance.
657#[cfg(any(target_os = "linux", test))]
658fn cgroup_headroom_mb(memory_max: &str, memory_current: &str) -> Option<u64> {
659    let max = memory_max.trim();
660    if max == "max" {
661        return None;
662    }
663    let max: u64 = max.parse().ok()?;
664    let current: u64 = memory_current.trim().parse().ok()?;
665    Some(max.saturating_sub(current) / (1024 * 1024))
666}
667
668struct Discovery {
669    cases: Vec<TestCase>,
670    files_with_tests: usize,
671    discovery_errors: Vec<TestResult>,
672}
673
674fn discover_test_cases(files: &[PathBuf], filter: Option<&str>, workers: usize) -> Discovery {
675    let mut cases = Vec::new();
676    let mut files_with_tests = 0usize;
677    let mut discovery_errors = Vec::new();
678
679    for file in files {
680        let source = match fs::read_to_string(file) {
681            Ok(s) => s,
682            Err(e) => {
683                discovery_errors.push(TestResult {
684                    name: "<file error>".to_string(),
685                    file: file.display().to_string(),
686                    passed: false,
687                    error: Some(format!("Failed to read {}: {e}", file.display())),
688                    captured_output: None,
689                    timeout: None,
690                    duration_ms: 0,
691                    phases: None,
692                });
693                continue;
694            }
695        };
696
697        let program = match parse_program(&source) {
698            Ok(p) => p,
699            Err(e) => {
700                discovery_errors.push(TestResult {
701                    name: "<file error>".to_string(),
702                    file: file.display().to_string(),
703                    passed: false,
704                    error: Some(e),
705                    captured_output: None,
706                    timeout: None,
707                    duration_ms: 0,
708                    phases: None,
709                });
710                continue;
711            }
712        };
713
714        let source = Arc::new(source);
715        let program = Arc::new(program);
716        match extract_cases_from_program(file, &source, &program, filter, workers) {
717            Ok(mut file_cases) => {
718                if !file_cases.is_empty() {
719                    seed_imported_enum_candidates(file, &source, &mut file_cases);
720                    files_with_tests += 1;
721                    cases.extend(file_cases);
722                }
723            }
724            Err(error) => discovery_errors.push(TestResult {
725                name: "<file error>".to_string(),
726                file: file.display().to_string(),
727                passed: false,
728                error: Some(error),
729                captured_output: None,
730                timeout: None,
731                duration_ms: 0,
732                phases: None,
733            }),
734        }
735    }
736
737    Discovery {
738        cases,
739        files_with_tests,
740        discovery_errors,
741    }
742}
743
744fn sort_cases_longest_first(cases: &mut [TestCase], timings: &BTreeMap<String, u64>) {
745    // Sort ascending so the slowest tests sit at the tail and get popped
746    // first by workers. New (unmeasured) tests share the bottom of the
747    // queue alongside the fastest known ones — they'll appear in stable
748    // file/name order, and once they get their first timing they'll
749    // float up to where they belong.
750    cases.sort_by(|a, b| {
751        let key_a = timings_key(&a.file, &a.name);
752        let key_b = timings_key(&b.file, &b.name);
753        let dur_a = timings.get(&key_a).copied().unwrap_or(0);
754        let dur_b = timings.get(&key_b).copied().unwrap_or(0);
755        dur_a
756            .cmp(&dur_b)
757            .then_with(|| a.file.cmp(&b.file))
758            .then_with(|| a.name.cmp(&b.name))
759    });
760}
761
762fn select_shard_cases(
763    cases: Vec<TestCase>,
764    timings: &BTreeMap<String, u64>,
765    shard: TestShard,
766) -> Vec<TestCase> {
767    if shard.total() <= 1 {
768        return cases;
769    }
770
771    let mut ranked = cases.into_iter().collect::<Vec<_>>();
772    ranked.sort_by(|a, b| {
773        estimated_case_cost_ms(b, timings)
774            .cmp(&estimated_case_cost_ms(a, timings))
775            .then_with(|| a.file.cmp(&b.file))
776            .then_with(|| a.name.cmp(&b.name))
777    });
778
779    let mut buckets = (0..shard.total()).map(|_| Vec::new()).collect::<Vec<_>>();
780    let mut costs = vec![0u64; shard.total()];
781    let mut counts = vec![0usize; shard.total()];
782
783    for case in ranked {
784        let bucket_index = (0..shard.total())
785            .min_by_key(|&index| (costs[index], counts[index], index))
786            .unwrap_or(0);
787        costs[bucket_index] =
788            costs[bucket_index].saturating_add(estimated_case_cost_ms(&case, timings));
789        counts[bucket_index] += 1;
790        buckets[bucket_index].push(case);
791    }
792
793    buckets.swap_remove(shard.index() - 1)
794}
795
796fn estimated_case_cost_ms(case: &TestCase, timings: &BTreeMap<String, u64>) -> u64 {
797    timings
798        .get(&timings_key(&case.file, &case.name))
799        .copied()
800        .unwrap_or(case.weight as u64)
801        .max(1)
802}
803
804fn count_files_with_cases(cases: &[TestCase]) -> usize {
805    let mut files = HashSet::new();
806    for case in cases {
807        files.insert(case.file.as_path());
808    }
809    files.len()
810}
811
812fn timings_key(file: &Path, name: &str) -> String {
813    format!("{}::{}", file.display(), name)
814}
815
816fn timings_cache_path(target: &Path) -> Option<PathBuf> {
817    // Anchor the cache at the project root if discoverable, otherwise at
818    // the directory the suite was launched from. The cache is shared
819    // across runs in the same workspace, so a per-suite cache would
820    // fragment timings whenever a user runs a subset.
821    let probe_root = if target.is_dir() {
822        target.to_path_buf()
823    } else {
824        target.parent()?.to_path_buf()
825    };
826    let root = harn_vm::stdlib::process::find_project_root(&probe_root)
827        .unwrap_or_else(|| probe_root.clone());
828    Some(root.join(TIMINGS_CACHE_RELATIVE_PATH))
829}
830
831fn load_timings_cache(path: &Path) -> BTreeMap<String, u64> {
832    let Ok(contents) = fs::read_to_string(path) else {
833        return BTreeMap::new();
834    };
835    serde_json::from_str::<BTreeMap<String, u64>>(&contents).unwrap_or_default()
836}
837
838fn update_timings_cache(path: &Path, mut existing: BTreeMap<String, u64>, results: &[TestResult]) {
839    for result in results {
840        existing.insert(
841            timings_key(Path::new(&result.file), &result.name),
842            result.duration_ms,
843        );
844    }
845    if let Some(parent) = path.parent() {
846        let _ = fs::create_dir_all(parent);
847    }
848    if let Ok(serialized) = serde_json::to_string(&existing) {
849        let _ = fs::write(path, serialized);
850    }
851}
852
853#[derive(Default)]
854struct CaseExecutionResults {
855    cases: Vec<TestResult>,
856    infrastructure_errors: Vec<TestResult>,
857}
858
859struct PreparedFixtureCases {
860    cases: Vec<TestCase>,
861    failures: Vec<TestResult>,
862}
863
864async fn prepare_file_fixtures(
865    cases: Vec<TestCase>,
866    options: &RunOptions,
867    session: &TestRunSession,
868    skill_contexts: &PreparedSkillContexts,
869    operator_approval_grant: Option<&harn_vm::orchestration::OperatorApprovalGrant>,
870) -> PreparedFixtureCases {
871    let mut values: BTreeMap<(PathBuf, String), Result<IsolateValue, TestResult>> = BTreeMap::new();
872    let mut prepared = Vec::with_capacity(cases.len());
873    let mut failures = Vec::new();
874    let prepared_module_cache = session.prepared_module_cache(0);
875
876    for mut case in cases {
877        let Some(fixture) = case
878            .fixture
879            .as_ref()
880            .filter(|fixture| fixture.scope == FixtureScope::File)
881            .cloned()
882        else {
883            prepared.push(case);
884            continue;
885        };
886        let key = (case.file.clone(), fixture.name.clone());
887        if !values.contains_key(&key) {
888            let cwd = case_execution_cwd(&case);
889            let value = execute_file_fixture(
890                &case,
891                &fixture,
892                &cwd,
893                options.timeout_ms,
894                skill_contexts.for_case(&case),
895                &prepared_module_cache,
896                session.stdio_available(),
897                operator_approval_grant,
898            )
899            .await;
900            if let Err(failure) = &value {
901                failures.push(failure.clone());
902            }
903            values.insert(key.clone(), value);
904        }
905        match values.get(&key).expect("fixture result inserted above") {
906            Ok(value) => {
907                case.file_fixture_value = Some(value.clone());
908                prepared.push(case);
909            }
910            Err(_) if options.fail_fast => {
911                prepared.clear();
912                break;
913            }
914            Err(_) => {}
915        }
916    }
917
918    PreparedFixtureCases {
919        cases: prepared,
920        failures,
921    }
922}
923
924async fn execute_cases(
925    cases: Vec<TestCase>,
926    workers: usize,
927    options: &RunOptions,
928    total_tests: usize,
929    session: &TestRunSession,
930    skill_contexts: PreparedSkillContexts,
931    operator_approval_grant: Option<&harn_vm::orchestration::OperatorApprovalGrant>,
932) -> CaseExecutionResults {
933    if cases.is_empty() {
934        return CaseExecutionResults::default();
935    }
936    let completed = Arc::new(Mutex::new(0usize));
937    if workers <= 1 {
938        let prepared_module_cache = session.prepared_module_cache(0);
939        let mut results = Vec::with_capacity(cases.len());
940        for case in cases {
941            let loaded_skills = skill_contexts.for_case(&case);
942            let cwd = case_execution_cwd(&case);
943            let test_index = next_test_index(&completed);
944            emit_progress(
945                &options.progress,
946                TestRunEvent::TestStarted {
947                    name: case.name.clone(),
948                    file: case.file.display().to_string(),
949                    test_index,
950                    total_tests,
951                },
952            );
953            let result = execute_case(
954                &case,
955                &cwd,
956                options.timeout_ms,
957                loaded_skills,
958                &prepared_module_cache,
959                session.stdio_available(),
960                operator_approval_grant,
961            )
962            .await;
963            let result = enforce_case_budgets(result, options.max_test_ms, options.max_execute_ms);
964            if options.diagnose {
965                result.emit_diagnose();
966            }
967            emit_progress(
968                &options.progress,
969                TestRunEvent::TestFinished(result.clone()),
970            );
971            results.push(result);
972            if options.fail_fast && !results.last().is_some_and(|result| result.passed) {
973                break;
974            }
975        }
976        return CaseExecutionResults {
977            cases: results,
978            infrastructure_errors: Vec::new(),
979        };
980    }
981
982    let queue = Arc::new(Mutex::new(cases));
983    let skill_contexts = Arc::new(skill_contexts);
984    let gate = Arc::new(ResourceGate::new(workers));
985    let results: Arc<Mutex<Vec<TestResult>>> = Arc::new(Mutex::new(Vec::new()));
986    let infrastructure_errors: Arc<Mutex<Vec<TestResult>>> = Arc::new(Mutex::new(Vec::new()));
987    let cancelled = Arc::new(AtomicBool::new(false));
988
989    let mut handles = Vec::with_capacity(workers);
990    for worker_idx in 0..workers {
991        let queue = Arc::clone(&queue);
992        let skill_contexts = Arc::clone(&skill_contexts);
993        let gate = Arc::clone(&gate);
994        let results = Arc::clone(&results);
995        let infrastructure_errors = Arc::clone(&infrastructure_errors);
996        let completed = Arc::clone(&completed);
997        let timeout_ms = options.timeout_ms;
998        let max_test_ms = options.max_test_ms;
999        let max_execute_ms = options.max_execute_ms;
1000        let progress = options.progress.clone();
1001        let diagnose = options.diagnose;
1002        let fail_fast = options.fail_fast;
1003        let cancelled = Arc::clone(&cancelled);
1004        let prepared_module_cache = session.prepared_module_cache(worker_idx);
1005        let stdio_available = session.stdio_available();
1006        let operator_approval_grant = operator_approval_grant.cloned();
1007        let handle = thread::Builder::new()
1008            .name(format!("harn-test-worker-{worker_idx}"))
1009            .stack_size(CLI_RUNTIME_STACK_SIZE)
1010            .spawn(move || {
1011                let runtime = match tokio::runtime::Builder::new_current_thread()
1012                    .enable_all()
1013                    .build()
1014                {
1015                    Ok(rt) => rt,
1016                    Err(error) => {
1017                        infrastructure_errors.lock().unwrap().push(TestResult {
1018                            name: "<worker error>".to_string(),
1019                            file: String::new(),
1020                            passed: false,
1021                            error: Some(format!("failed to start test runtime: {error}")),
1022                            captured_output: None,
1023                            timeout: None,
1024                            duration_ms: 0,
1025                            phases: None,
1026                        });
1027                        return;
1028                    }
1029                };
1030                // Cases are sorted ascending by historical duration; popping
1031                // from the tail gives this worker the slowest unclaimed
1032                // test, which front-loads long poles and prevents workers
1033                // from stranding on quick tests at the end of the run.
1034                loop {
1035                    let case = claim_next_case(&queue, &cancelled, fail_fast);
1036                    let Some(case) = case else { break };
1037                    let _guard = gate.acquire(case.weight, case.serial_group.as_deref());
1038                    // A worker may have claimed this case before another
1039                    // worker failed, then waited behind a heavy/serial gate.
1040                    // Recheck at the execution barrier so queued work is not
1041                    // mistaken for already-running work under fail-fast.
1042                    if fail_fast && cancelled.load(Ordering::Acquire) {
1043                        break;
1044                    }
1045                    let cwd = case_execution_cwd(&case);
1046                    let loaded_skills = skill_contexts.for_case(&case);
1047                    let test_index = next_test_index(&completed);
1048                    emit_progress(
1049                        &progress,
1050                        TestRunEvent::TestStarted {
1051                            name: case.name.clone(),
1052                            file: case.file.display().to_string(),
1053                            test_index,
1054                            total_tests,
1055                        },
1056                    );
1057                    let result = runtime.block_on(execute_case(
1058                        &case,
1059                        &cwd,
1060                        timeout_ms,
1061                        loaded_skills,
1062                        &prepared_module_cache,
1063                        stdio_available,
1064                        operator_approval_grant.as_ref(),
1065                    ));
1066                    let result = enforce_case_budgets(result, max_test_ms, max_execute_ms);
1067                    if fail_fast && !result.passed {
1068                        cancelled.store(true, Ordering::Release);
1069                    }
1070                    if diagnose {
1071                        result.emit_diagnose();
1072                    }
1073                    emit_progress(&progress, TestRunEvent::TestFinished(result.clone()));
1074                    results.lock().unwrap().push(result);
1075                }
1076            })
1077            .expect("spawning a harn-test worker thread should succeed");
1078        handles.push(handle);
1079    }
1080
1081    for handle in handles {
1082        let _ = handle.join();
1083    }
1084
1085    // All workers have joined, so this Arc holds the only remaining
1086    // reference. The lock-and-clone fallback survives the unlikely case
1087    // where a panic-unwind kept an extra reference alive.
1088    let cases = Arc::try_unwrap(results)
1089        .map(|m| m.into_inner().unwrap_or_default())
1090        .unwrap_or_else(|arc| arc.lock().unwrap().clone());
1091    let infrastructure_errors = Arc::try_unwrap(infrastructure_errors)
1092        .map(|mutex| mutex.into_inner().unwrap_or_default())
1093        .unwrap_or_else(|arc| arc.lock().unwrap().clone());
1094    CaseExecutionResults {
1095        cases,
1096        infrastructure_errors,
1097    }
1098}
1099
1100fn claim_next_case(
1101    queue: &Mutex<Vec<TestCase>>,
1102    cancelled: &AtomicBool,
1103    fail_fast: bool,
1104) -> Option<TestCase> {
1105    let mut queue = queue.lock().unwrap();
1106    if fail_fast && cancelled.load(Ordering::Acquire) {
1107        None
1108    } else {
1109        queue.pop()
1110    }
1111}
1112
1113fn enforce_case_budgets(
1114    mut result: TestResult,
1115    max_test_ms: Option<u64>,
1116    max_execute_ms: Option<u64>,
1117) -> TestResult {
1118    if !result.passed {
1119        return result;
1120    }
1121
1122    let phases = result
1123        .phases
1124        .expect("passed test results always carry measured phases");
1125    let mut violations = Vec::new();
1126    if let Some(max_ms) = max_test_ms {
1127        if result.duration_ms > max_ms {
1128            violations.push(format!(
1129                "exceeded test wall-clock budget: {}ms > {}ms",
1130                result.duration_ms, max_ms
1131            ));
1132        }
1133    }
1134    if let Some(max_ms) = max_execute_ms {
1135        if phases.execute_ms > max_ms {
1136            violations.push(format!(
1137                "exceeded test execute budget: {}ms > {}ms",
1138                phases.execute_ms, max_ms
1139            ));
1140        }
1141    }
1142
1143    if violations.is_empty() {
1144        return result;
1145    }
1146
1147    violations.push(format!(
1148        "phase timings: setup={}ms compile={}ms execute={}ms teardown={}ms total={}ms",
1149        phases.setup_ms,
1150        phases.compile_ms,
1151        phases.execute_ms,
1152        phases.teardown_ms,
1153        result.duration_ms
1154    ));
1155    result.passed = false;
1156    result.error = Some(violations.join("\n"));
1157    result
1158}
1159
1160fn next_test_index(counter: &Mutex<usize>) -> usize {
1161    let mut guard = counter.lock().unwrap();
1162    *guard += 1;
1163    *guard
1164}
1165
1166fn case_execution_cwd(case: &TestCase) -> PathBuf {
1167    case.file
1168        .parent()
1169        .filter(|p| !p.as_os_str().is_empty())
1170        .map(Path::to_path_buf)
1171        .unwrap_or_else(test_execution_cwd)
1172}
1173
1174/// Coordinates worker permits and serial-group exclusivity without
1175/// requiring an async lock — workers are dedicated OS threads, so a
1176/// classic Mutex+Condvar gate keeps everything off the tokio scheduler.
1177struct ResourceGate {
1178    state: Mutex<GateState>,
1179    cond: Condvar,
1180    capacity: usize,
1181}
1182
1183struct GateState {
1184    available: usize,
1185    busy_groups: HashSet<String>,
1186}
1187
1188struct GateGuard<'a> {
1189    gate: &'a ResourceGate,
1190    weight: usize,
1191    group: Option<String>,
1192}
1193
1194impl ResourceGate {
1195    fn new(capacity: usize) -> Self {
1196        Self {
1197            state: Mutex::new(GateState {
1198                available: capacity,
1199                busy_groups: HashSet::new(),
1200            }),
1201            cond: Condvar::new(),
1202            capacity,
1203        }
1204    }
1205
1206    fn acquire(&self, weight: usize, group: Option<&str>) -> GateGuard<'_> {
1207        let weight = weight.min(self.capacity).max(1);
1208        let mut state = self.state.lock().unwrap();
1209        loop {
1210            if let Some(guard) = self.try_grab_locked(&mut state, weight, group) {
1211                return guard;
1212            }
1213            state = self.cond.wait(state).unwrap();
1214        }
1215    }
1216
1217    /// Grab a permit if one is immediately available, holding the already-locked
1218    /// state. Returns `None` without blocking when the pool is exhausted or the
1219    /// group is busy. Shared by `acquire` (which retries) and `try_acquire`.
1220    fn try_grab_locked<'a>(
1221        &'a self,
1222        state: &mut GateState,
1223        weight: usize,
1224        group: Option<&str>,
1225    ) -> Option<GateGuard<'a>> {
1226        let group_free = group.is_none_or(|g| !state.busy_groups.contains(g));
1227        if state.available >= weight && group_free {
1228            state.available -= weight;
1229            if let Some(g) = group {
1230                state.busy_groups.insert(g.to_string());
1231            }
1232            return Some(GateGuard {
1233                gate: self,
1234                weight,
1235                group: group.map(str::to_owned),
1236            });
1237        }
1238        None
1239    }
1240
1241    /// Non-blocking variant of `acquire` used by tests to assert gate state
1242    /// deterministically (in-process, no threads or wall-clock sleeps).
1243    #[cfg(test)]
1244    fn try_acquire(&self, weight: usize, group: Option<&str>) -> Option<GateGuard<'_>> {
1245        let weight = weight.min(self.capacity).max(1);
1246        let mut state = self.state.lock().unwrap();
1247        self.try_grab_locked(&mut state, weight, group)
1248    }
1249}
1250
1251impl Drop for GateGuard<'_> {
1252    fn drop(&mut self) {
1253        let mut state = self.gate.state.lock().unwrap();
1254        state.available += self.weight;
1255        if let Some(group) = self.group.as_deref() {
1256            state.busy_groups.remove(group);
1257        }
1258        self.gate.cond.notify_all();
1259    }
1260}
1261
1262fn discover_test_files(dir: &Path) -> Vec<PathBuf> {
1263    let mut files = Vec::new();
1264    if let Ok(entries) = fs::read_dir(dir) {
1265        for entry in entries.flatten() {
1266            let path = entry.path();
1267            if path.is_dir() {
1268                files.extend(discover_test_files(&path));
1269            } else if path.extension().is_some_and(|e| e == "harn") {
1270                if let Ok(content) = fs::read_to_string(&path) {
1271                    if content.contains("test_") || content.contains("@test") {
1272                        files.push(canonicalize_existing_path(&path));
1273                    }
1274                }
1275            }
1276        }
1277    }
1278    files.sort();
1279    files
1280}