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