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