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