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