Skip to main content

scx_forge_agent/
validate.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: GPL-2.0-only
3//! Native validation harness: the agent's reward function.
4//!
5//! Builds the scheduler, attaches it on the host (as root), drives a workload,
6//! extracts a single metric, always tears the scheduler down (verifying the
7//! kernel returns to `disabled`), and returns a [`Verdict`]. Runs in-process;
8//! the equivalent logic previously lived in a separate `run_validation.py`.
9//!
10//! Stages mirror the old harness: `spec` / `build` / `preflight` / `attach` /
11//! `metric` / `complete`. A non-complete verdict means the candidate did not
12//! build, attach, or produce a measurable result this round.
13
14use std::collections::HashMap;
15use std::fs::File;
16use std::io::{BufRead, BufReader};
17use std::os::unix::process::CommandExt;
18use std::path::{Path, PathBuf};
19use std::process::{Child, Command, Stdio};
20use std::sync::atomic::AtomicBool;
21use std::thread::sleep;
22use std::time::{Duration, Instant};
23
24use anyhow::{Context, Result};
25use serde_json::{Value, json};
26
27use crate::cargo_program;
28use crate::color::Style;
29use crate::interrupt;
30use crate::progress::ProgressSpinner;
31use crate::spec::{METRIC_NAME, Spec};
32use crate::sudo::Sudo;
33
34const SCX_STATE_PATH: &str = "/sys/kernel/sched_ext/state";
35
36#[derive(Debug, Clone)]
37pub struct Verdict {
38    pub ok: bool,
39    pub stage: String,
40    pub metric_name: String,
41    pub goal: String,
42    pub value: Option<f64>,
43    pub median: Option<f64>,
44    pub stddev: Option<f64>,
45    pub errors: Vec<String>,
46    pub raw: Value,
47}
48
49impl Verdict {
50    pub fn is_complete(&self) -> bool {
51        self.ok && self.stage == "complete" && self.value.is_some()
52    }
53}
54
55/// Return the sched_ext root state ("disabled"/"enabled"/...) or None.
56fn scx_state() -> Option<String> {
57    std::fs::read_to_string(SCX_STATE_PATH)
58        .ok()
59        .map(|s| s.trim().to_string())
60}
61
62/// Poll the sched_ext state until it equals `want` or the timeout elapses.
63fn wait_for_state(want: &str, timeout: Duration, interrupted: &AtomicBool) -> bool {
64    let deadline = Instant::now() + timeout;
65    loop {
66        if scx_state().as_deref() == Some(want) {
67            return true;
68        }
69        if interrupt::requested(interrupted) {
70            return false;
71        }
72        if Instant::now() >= deadline {
73            return scx_state().as_deref() == Some(want);
74        }
75        sleep(Duration::from_millis(100));
76    }
77}
78
79fn scx_is_enabled(state: Option<&str>) -> bool {
80    state == Some("enabled")
81}
82
83/// Sleep up to `duration`, returning true if interrupted before the deadline.
84fn sleep_interruptible(duration: Duration, interrupted: &AtomicBool) -> bool {
85    let deadline = Instant::now() + duration;
86    loop {
87        if interrupt::requested(interrupted) {
88            return true;
89        }
90        let now = Instant::now();
91        if now >= deadline {
92            return false;
93        }
94        sleep((deadline - now).min(Duration::from_millis(100)));
95    }
96}
97
98/// cargo build the candidate. Returns (ok, combined stdout+stderr).
99fn cargo_build(
100    repo_root: &Path,
101    package: &str,
102    profile: &str,
103    interrupted: &AtomicBool,
104) -> (bool, String) {
105    let cargo = cargo_program();
106    let mut command = Command::new(&cargo);
107    command
108        .args(["build", "--profile", profile, "-p", package])
109        .current_dir(repo_root)
110        .stdout(Stdio::piped())
111        .stderr(Stdio::piped());
112    unsafe {
113        command.pre_exec(|| {
114            libc::setsid();
115            Ok(())
116        });
117    }
118    let mut child = match command.spawn() {
119        Ok(child) => child,
120        Err(e) => {
121            return (
122                false,
123                format!("spawn {} build: {e}", cargo.to_string_lossy()),
124            );
125        }
126    };
127    let pid = child.id() as i32;
128    loop {
129        if interrupt::requested(interrupted) {
130            unsafe {
131                libc::kill(-pid, libc::SIGINT);
132            }
133            let deadline = Instant::now() + Duration::from_secs(2);
134            loop {
135                match child.try_wait() {
136                    Ok(Some(_)) | Err(_) => break,
137                    Ok(None) if Instant::now() >= deadline => {
138                        unsafe {
139                            libc::kill(-pid, libc::SIGKILL);
140                        }
141                        break;
142                    }
143                    Ok(None) => sleep(Duration::from_millis(50)),
144                }
145            }
146            let out = match child.wait_with_output() {
147                Ok(out) => out,
148                Err(e) => {
149                    return (
150                        false,
151                        format!("cargo build interrupted; collect output: {e}"),
152                    );
153                }
154            };
155            let mut s = String::from("cargo build interrupted by Ctrl-C\n");
156            s.push_str(&String::from_utf8_lossy(&out.stdout));
157            s.push_str(&String::from_utf8_lossy(&out.stderr));
158            return (false, s);
159        }
160        match child.try_wait() {
161            Ok(Some(_)) => break,
162            Ok(None) => sleep(Duration::from_millis(100)),
163            Err(e) => {
164                return (
165                    false,
166                    format!("poll {} build: {e}", cargo.to_string_lossy()),
167                );
168            }
169        }
170    }
171    let out = match child.wait_with_output() {
172        Ok(out) => out,
173        Err(e) => {
174            return (
175                false,
176                format!("collect {} build output: {e}", cargo.to_string_lossy()),
177            );
178        }
179    };
180    let mut s = String::from_utf8_lossy(&out.stdout).to_string();
181    s.push_str(&String::from_utf8_lossy(&out.stderr));
182    (out.status.success(), s)
183}
184
185/// cargo maps release/dev to target/{release,debug}; named profiles land in
186/// target/<profile>.
187pub(crate) fn binary_path(repo_root: &Path, package: &str, profile: &str) -> PathBuf {
188    let subdir = match profile {
189        "release" => "release",
190        "dev" => "debug",
191        other => other,
192    };
193    repo_root.join("target").join(subdir).join(package)
194}
195
196fn read_lossy(path: &Path) -> String {
197    std::fs::read(path)
198        .map(|b| String::from_utf8_lossy(&b).into_owned())
199        .unwrap_or_default()
200}
201
202fn perf_available() -> bool {
203    Command::new("perf")
204        .arg("--version")
205        .stdout(Stdio::null())
206        .stderr(Stdio::null())
207        .status()
208        .is_ok()
209}
210
211/// Per-CPU mmap pages for `perf record -m`, derived from a total byte budget
212/// split across the online CPUs. This controls perf's live mmap buffer; the
213/// output file is separately capped with `perf record --max-size`.
214fn perf_mmap_pages_per_cpu(max_total_bytes: u64) -> u64 {
215    let cpus = std::thread::available_parallelism()
216        .map(|n| n.get() as u64)
217        .unwrap_or(1)
218        .max(1);
219    const PAGE_SIZE: u64 = 4096;
220    let pages = (max_total_bytes / PAGE_SIZE / cpus).max(1);
221    let mut pow2 = 1;
222    while pow2 <= pages / 2 {
223        pow2 *= 2;
224    }
225    pow2
226}
227
228#[derive(Default, Debug)]
229struct TraceStats {
230    start_ts: Option<f64>,
231    end_ts: Option<f64>,
232    events: HashMap<String, u64>,
233    per_cpu_events: HashMap<String, u64>,
234}
235
236impl TraceStats {
237    fn total(&self) -> u64 {
238        self.events.values().sum()
239    }
240}
241
242fn trace_cpu(line: &str) -> Option<String> {
243    let start = line.find('[')? + 1;
244    let end = line[start..].find(']')? + start;
245    Some(line[start..end].trim().to_string())
246}
247
248fn trace_event(line: &str) -> Option<(f64, String, Option<String>)> {
249    let cpu = trace_cpu(line);
250    let cpu_end = line.find(']')? + 1;
251    let mut tokens = line[cpu_end..].split_whitespace();
252    while let Some(token) = tokens.next() {
253        let ts_token = token.trim_end_matches(':');
254        if let Ok(ts) = ts_token.parse::<f64>() {
255            for token in tokens {
256                let event = token.trim_end_matches(':');
257                if event.is_empty() || event.parse::<f64>().is_ok() {
258                    continue;
259                }
260                return Some((ts, event.to_string(), cpu));
261            }
262            return None;
263        }
264    }
265    None
266}
267
268fn update_trace_stats(stats: &mut TraceStats, line: &str) {
269    if let Some((ts, event, cpu)) = trace_event(line) {
270        stats.start_ts = Some(stats.start_ts.map_or(ts, |start| start.min(ts)));
271        stats.end_ts = Some(stats.end_ts.map_or(ts, |end| end.max(ts)));
272        *stats.events.entry(event).or_insert(0) += 1;
273        if let Some(cpu) = cpu {
274            *stats.per_cpu_events.entry(cpu).or_insert(0) += 1;
275        }
276    }
277}
278
279fn trace_window_sec(start_ts: Option<f64>, end_ts: Option<f64>) -> Option<f64> {
280    let window = end_ts? - start_ts?;
281    (window > 0.0).then_some(window)
282}
283
284fn round_trace_float(value: f64) -> f64 {
285    (value * 1_000_000.0).round() / 1_000_000.0
286}
287
288fn rounded_option(value: Option<f64>) -> Value {
289    match value {
290        Some(value) => json!(round_trace_float(value)),
291        None => Value::Null,
292    }
293}
294
295fn rate_value(count: u64, window: Option<f64>) -> Value {
296    match window {
297        Some(window) => json!(round_trace_float(count as f64 / window)),
298        None => Value::Null,
299    }
300}
301
302fn sorted_counts(map: &HashMap<String, u64>) -> Vec<(&String, &u64)> {
303    let mut v = map.iter().collect::<Vec<_>>();
304    v.sort_by(|(ka, va), (kb, vb)| vb.cmp(va).then_with(|| ka.cmp(kb)));
305    v
306}
307
308fn event_counts_json(events: &HashMap<String, u64>) -> Value {
309    json!(
310        sorted_counts(events)
311            .into_iter()
312            .map(|(event, count)| json!({"event": event, "count": count}))
313            .collect::<Vec<_>>()
314    )
315}
316
317fn event_rates_json(events: &HashMap<String, u64>, window: Option<f64>) -> Value {
318    json!(
319        sorted_counts(events)
320            .into_iter()
321            .map(|(event, count)| json!({"event": event, "per_sec": rate_value(*count, window)}))
322            .collect::<Vec<_>>()
323    )
324}
325
326fn cpu_event_rate_summary(stats: &TraceStats, window: Option<f64>, limit: usize) -> Value {
327    let mut cpus = stats.per_cpu_events.iter().collect::<Vec<_>>();
328    cpus.sort_by(|(cpu_a, count_a), (cpu_b, count_b)| {
329        count_b.cmp(count_a).then_with(|| cpu_a.cmp(cpu_b))
330    });
331
332    let rates = match window {
333        Some(window) => cpus
334            .iter()
335            .map(|(_, count)| **count as f64 / window)
336            .collect::<Vec<_>>(),
337        None => Vec::new(),
338    };
339    let max_rate = rates.iter().copied().reduce(f64::max);
340    let median_rate = if rates.is_empty() {
341        None
342    } else {
343        Some(median(&rates))
344    };
345    let max_to_median = match (max_rate, median_rate) {
346        (Some(max), Some(median)) if median > 0.0 => Some(max / median),
347        _ => None,
348    };
349    let top_cpus = cpus
350        .iter()
351        .take(limit)
352        .map(|(cpu, count)| {
353            json!({
354                "cpu": cpu,
355                "events": count,
356                "events_per_sec": rate_value(**count, window),
357            })
358        })
359        .collect::<Vec<_>>();
360
361    json!({
362        "cpus_observed": stats.per_cpu_events.len(),
363        "rates_scope": "per-CPU observed event totals divided by the global retained trace_window.duration_sec",
364        "median_events_per_sec": rounded_option(median_rate),
365        "max_events_per_sec": rounded_option(max_rate),
366        "max_to_median_ratio": rounded_option(max_to_median),
367        "top_cpus_by_total_events": top_cpus,
368    })
369}
370
371fn trace_stats_json(
372    stats: &TraceStats,
373    trace_path: &Path,
374    report_path: &Path,
375    events: &[String],
376) -> Value {
377    let window = trace_window_sec(stats.start_ts, stats.end_ts);
378    json!({
379        "enabled": true,
380        "backend": "perf",
381        "events": events,
382        "artifacts": {
383            "perf_data": trace_path.display().to_string(),
384            "perf_script": report_path.display().to_string(),
385        },
386        "trace_window": {
387            "start_ts": rounded_option(stats.start_ts),
388            "end_ts": rounded_option(stats.end_ts),
389            "duration_sec": rounded_option(window),
390            "scope": "retained perf script sample window; max-size limits may make this shorter than the workload",
391        },
392        "event_count_semantics": "perf sample records. Tracepoints are sampled per occurrence; hardware PMU events are sampled according to perf's configured/default sampling period, not raw hardware event totals.",
393        "event_count_total": stats.total(),
394        "event_rate_total_per_sec": rate_value(stats.total(), window),
395        "event_counts": event_counts_json(&stats.events),
396        "event_rates_per_sec": event_rates_json(&stats.events, window),
397        "cpu_event_rate_summary": cpu_event_rate_summary(stats, window, 8),
398    })
399}
400
401struct TraceRecorder {
402    child: Child,
403    trace_path: PathBuf,
404    log_path: PathBuf,
405}
406
407impl TraceRecorder {
408    fn start(
409        sudo: &Sudo,
410        trace_path: PathBuf,
411        log_path: PathBuf,
412        max_total_bytes: u64,
413        max_size_arg: &str,
414        events: &[String],
415    ) -> Result<Self> {
416        let _ = std::fs::remove_file(&trace_path);
417        let _ = sudo
418            .command("rm", &["-f".to_string(), trace_path.display().to_string()])
419            .status();
420        let log = File::create(&log_path)
421            .with_context(|| format!("create perf record log {}", log_path.display()))?;
422        let log_err = log.try_clone().context("clone perf record log handle")?;
423        let mut args = vec![
424            "record".to_string(),
425            "-a".to_string(),
426            "-q".to_string(),
427            "--sample-cpu".to_string(),
428            "-T".to_string(),
429            "-o".to_string(),
430            trace_path.display().to_string(),
431            "--max-size".to_string(),
432            max_size_arg.to_string(),
433            "-m".to_string(),
434            perf_mmap_pages_per_cpu(max_total_bytes).to_string(),
435        ];
436        for event in events {
437            args.push("-e".to_string());
438            args.push(event.to_string());
439        }
440        args.push("--".to_string());
441        args.push("sleep".to_string());
442        args.push("2147483647".to_string());
443        let mut command = sudo.command("perf", &args);
444        command.stdout(log).stderr(log_err).stdin(Stdio::null());
445        unsafe {
446            command.pre_exec(|| {
447                libc::setsid();
448                Ok(())
449            });
450        }
451        let mut child = command
452            .spawn()
453            .with_context(|| format!("spawn perf record {}", trace_path.display()))?;
454        sleep(Duration::from_millis(250));
455        if let Some(status) = child.try_wait().context("poll perf startup")? {
456            anyhow::bail!(
457                "perf record exited during startup with {status}; log tail:\n{}",
458                tail(&read_lossy(&log_path), 1000)
459            );
460        }
461        Ok(Self {
462            child,
463            trace_path,
464            log_path,
465        })
466    }
467
468    fn stop(mut self, sudo: &Sudo, events: &[String]) -> Value {
469        let pid = self.child.id() as i32;
470        unsafe {
471            libc::kill(-pid, libc::SIGINT);
472        }
473        let deadline = Instant::now() + Duration::from_secs(10);
474        let mut stopped = false;
475        loop {
476            match self.child.try_wait() {
477                Ok(Some(_)) => {
478                    stopped = true;
479                    break;
480                }
481                Ok(None) => {
482                    if Instant::now() >= deadline {
483                        break;
484                    }
485                    sleep(Duration::from_millis(100));
486                }
487                Err(_) => break,
488            }
489        }
490        if !stopped {
491            unsafe {
492                libc::kill(-pid, libc::SIGKILL);
493            }
494            let _ = self.child.wait();
495        }
496        summarize_trace(sudo, &self.trace_path, &self.log_path, events)
497    }
498}
499
500fn summarize_trace(sudo: &Sudo, trace_path: &Path, log_path: &Path, events: &[String]) -> Value {
501    if !trace_path.exists() {
502        return json!({
503            "enabled": false,
504            "backend": "perf",
505            "error": format!("perf record did not create {}", trace_path.display()),
506            "log_tail": tail(&read_lossy(log_path), 1000),
507        });
508    }
509    let report_path = trace_path.with_extension("script");
510    let report = match File::create(&report_path) {
511        Ok(f) => f,
512        Err(e) => {
513            return json!({
514                "enabled": false,
515                "backend": "perf",
516                "perf_data": trace_path.display().to_string(),
517                "error": format!("create perf script report {}: {e}", report_path.display()),
518                "log_tail": tail(&read_lossy(log_path), 1000),
519            });
520        }
521    };
522    let report_err = match report.try_clone() {
523        Ok(f) => f,
524        Err(e) => {
525            return json!({
526                "enabled": false,
527                "backend": "perf",
528                "perf_data": trace_path.display().to_string(),
529                "error": format!("clone perf script report handle: {e}"),
530                "log_tail": tail(&read_lossy(log_path), 1000),
531            });
532        }
533    };
534    let args = vec![
535        "script".to_string(),
536        "--ns".to_string(),
537        "-F".to_string(),
538        "comm,pid,cpu,time,event".to_string(),
539        "-i".to_string(),
540        trace_path.display().to_string(),
541    ];
542    let status = sudo
543        .command("perf", &args)
544        .stdout(report)
545        .stderr(report_err)
546        .status();
547    if !matches!(status, Ok(s) if s.success()) {
548        return json!({
549            "enabled": false,
550            "backend": "perf",
551            "perf_data": trace_path.display().to_string(),
552            "perf_script": report_path.display().to_string(),
553            "error": format!("perf script failed: {:?}", status),
554            "report_tail": tail(&read_lossy(&report_path), 1000),
555            "log_tail": tail(&read_lossy(log_path), 1000),
556        });
557    }
558
559    let file = match File::open(&report_path) {
560        Ok(f) => f,
561        Err(e) => {
562            return json!({
563                "enabled": false,
564                "backend": "perf",
565                "perf_data": trace_path.display().to_string(),
566                "perf_script": report_path.display().to_string(),
567                "error": format!("open perf script report: {e}"),
568                "log_tail": tail(&read_lossy(log_path), 1000),
569            });
570        }
571    };
572    let mut stats = TraceStats::default();
573    for line in BufReader::new(file).lines().map_while(Result::ok) {
574        update_trace_stats(&mut stats, &line);
575    }
576    trace_stats_json(&stats, trace_path, &report_path, events)
577}
578
579/// Run a shell command to completion or until `timeout`, capturing combined
580/// stdout+stderr to `out_path`. A fixed-duration workload is expected to be cut
581/// off at the timeout; its partial output is still returned.
582fn run_capture(
583    cmd: &str,
584    timeout: Duration,
585    out_path: &Path,
586    progress: Option<Style>,
587    label: String,
588    interrupted: &AtomicBool,
589) -> Result<String> {
590    let out = File::create(out_path)
591        .with_context(|| format!("create workload log {}", out_path.display()))?;
592    let err = out.try_clone().context("clone workload log handle")?;
593    let mut command = Command::new("sh");
594    command
595        .arg("-c")
596        .arg(cmd)
597        .stdout(out)
598        .stderr(err)
599        .stdin(Stdio::null());
600    // New session so we can signal the whole process group on timeout.
601    unsafe {
602        command.pre_exec(|| {
603            libc::setsid();
604            Ok(())
605        });
606    }
607    let mut child = command
608        .spawn()
609        .with_context(|| format!("spawn workload: {cmd}"))?;
610    let _spinner = progress.map(|style| ProgressSpinner::stdout(label, style));
611    let pid = child.id() as i32;
612    let deadline = Instant::now() + timeout;
613    loop {
614        match child.try_wait().context("poll workload")? {
615            Some(_) => break,
616            None => {
617                if interrupt::requested(interrupted) {
618                    unsafe {
619                        libc::kill(-pid, libc::SIGINT);
620                    }
621                    let deadline = Instant::now() + Duration::from_secs(2);
622                    loop {
623                        match child.try_wait().context("poll interrupted workload")? {
624                            Some(_) => break,
625                            None if Instant::now() >= deadline => {
626                                unsafe {
627                                    libc::kill(-pid, libc::SIGKILL);
628                                }
629                                let _ = child.wait();
630                                break;
631                            }
632                            None => sleep(Duration::from_millis(50)),
633                        }
634                    }
635                    break;
636                }
637                if Instant::now() >= deadline {
638                    unsafe {
639                        libc::kill(-pid, libc::SIGKILL);
640                    }
641                    let _ = child.wait();
642                    break;
643                }
644                sleep(Duration::from_millis(100));
645            }
646        }
647    }
648    Ok(read_lossy(out_path))
649}
650
651/// Parse the workload command output as the single numeric metric value. Any
652/// extraction or aggregation belongs in the command itself.
653fn extract_metric(text: &str) -> Option<f64> {
654    let value = text.trim();
655    if value.is_empty() {
656        return None;
657    }
658    value.parse::<f64>().ok()
659}
660
661fn median(values: &[f64]) -> f64 {
662    let mut v = values.to_vec();
663    v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
664    let n = v.len();
665    if n % 2 == 1 {
666        v[n / 2]
667    } else {
668        (v[n / 2 - 1] + v[n / 2]) / 2.0
669    }
670}
671
672/// Population standard deviation (matches Python statistics.pstdev).
673fn pstdev(values: &[f64]) -> f64 {
674    let n = values.len();
675    if n <= 1 {
676        return 0.0;
677    }
678    let mean = values.iter().sum::<f64>() / n as f64;
679    let var = values.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n as f64;
680    var.sqrt()
681}
682
683/// Capture the scheduler binary's `--help` text (stdout+stderr) so we can probe
684/// which optional flags it supports before launching it. Passing an unknown
685/// argument makes the scheduler exit before attaching, so flags that are not
686/// universal must be gated on this. Empty string on any failure.
687fn binary_help(binary: &Path) -> String {
688    match std::process::Command::new(binary).arg("--help").output() {
689        Ok(out) => {
690            let mut help = String::from_utf8_lossy(&out.stdout).into_owned();
691            help.push_str(&String::from_utf8_lossy(&out.stderr));
692            help
693        }
694        Err(_) => String::new(),
695    }
696}
697
698/// Launch/teardown of the scheduler with hard safety guarantees.
699struct Scheduler {
700    binary: PathBuf,
701    stats_interval: u64,
702    supports_stats: bool,
703    supports_verbose: bool,
704    log_path: PathBuf,
705    child: Option<Child>,
706}
707
708impl Scheduler {
709    fn new(binary: PathBuf, stats_interval: u64, log_path: PathBuf) -> Self {
710        let help = binary_help(&binary);
711        Scheduler {
712            binary,
713            stats_interval,
714            supports_stats: help.contains("--stats"),
715            supports_verbose: help.contains("--verbose"),
716            log_path,
717            child: None,
718        }
719    }
720
721    fn start(&mut self, sudo: &Sudo) -> Result<()> {
722        let log = File::create(&self.log_path)
723            .with_context(|| format!("create scheduler log {}", self.log_path.display()))?;
724        let log_err = log.try_clone().context("clone scheduler log handle")?;
725        let mut full_args: Vec<String> = Vec::new();
726        // Run verbose so libbpf prints the BPF verifier log to the scheduler
727        // log: when a candidate fails to load/attach, that log is the only way
728        // to see why (the rejected program and the verifier's reason).
729        if self.supports_verbose {
730            full_args.push("--verbose".into());
731        }
732        // Only request periodic stats from schedulers that implement `--stats`;
733        // passing it to one that does not makes it exit before attaching.
734        if self.supports_stats {
735            full_args.push("--stats".into());
736            full_args.push(self.stats_interval.to_string());
737        }
738        let mut command = sudo.command(&self.binary.to_string_lossy(), &full_args);
739        command.stdout(log).stderr(log_err).stdin(Stdio::null());
740        // New session so teardown can signal the whole process group.
741        unsafe {
742            command.pre_exec(|| {
743                libc::setsid();
744                Ok(())
745            });
746        }
747        self.child = Some(
748            command
749                .spawn()
750                .with_context(|| format!("spawn scheduler {}", self.binary.display()))?,
751        );
752        Ok(())
753    }
754
755    fn log_text(&self) -> String {
756        read_lossy(&self.log_path)
757    }
758
759    /// Best-effort graceful then forceful teardown. Returns the final scx state.
760    fn stop(&mut self, sudo: &Sudo) -> Option<String> {
761        if let Some(child) = self.child.as_mut()
762            && matches!(child.try_wait(), Ok(None))
763        {
764            // sudo forwards SIGINT to its child; this is the clean path.
765            unsafe {
766                libc::kill(child.id() as i32, libc::SIGINT);
767            }
768            let deadline = Instant::now() + Duration::from_secs(8);
769            loop {
770                match child.try_wait() {
771                    Ok(Some(_)) | Err(_) => break,
772                    Ok(None) => {
773                        if Instant::now() >= deadline {
774                            break;
775                        }
776                        sleep(Duration::from_millis(100));
777                    }
778                }
779            }
780        }
781        // If still attached, escalate with sudo pkill on the binary basename
782        // (our child is the sudo wrapper, so a plain kill is not enough to reach
783        // a root-owned scheduler).
784        let base = self
785            .binary
786            .file_name()
787            .map(|s| s.to_string_lossy().into_owned())
788            .unwrap_or_default();
789        for sig in ["-INT", "-KILL"] {
790            if !matches!(scx_state().as_deref(), None | Some("disabled")) {
791                let _ = sudo
792                    .command("pkill", &[sig.into(), "-x".into(), base.clone()])
793                    .status();
794                let never_interrupted = AtomicBool::new(false);
795                wait_for_state("disabled", Duration::from_secs(6), &never_interrupted);
796            }
797        }
798        scx_state()
799    }
800}
801
802/// Replace this process with the scheduler in the current terminal.
803///
804/// This is used only for `--keep-running`, after the final report has been
805/// printed. It intentionally passes no scheduler flags, so the final handoff does
806/// not force `--verbose` or periodic `--stats` output.
807pub fn exec_scheduler_foreground(
808    repo_root: &Path,
809    package: &str,
810    profile: &str,
811    sudo: &Sudo,
812) -> Result<()> {
813    match scx_state() {
814        None => {
815            anyhow::bail!("{SCX_STATE_PATH} not found; kernel lacks sched_ext");
816        }
817        Some(s) if s != "disabled" => {
818            anyhow::bail!("a scheduler is already attached (state={s:?})");
819        }
820        _ => {}
821    }
822    sudo.authenticate().context("sudo authentication failed")?;
823
824    let sched_bin = binary_path(repo_root, package, profile);
825    if !sched_bin.exists() {
826        anyhow::bail!("scheduler binary not found at {}", sched_bin.display());
827    }
828
829    let sched_prog = sched_bin.to_string_lossy().into_owned();
830    let args = Vec::new();
831    let mut command = sudo.command(&sched_prog, &args);
832    Err::<(), _>(command.exec()).with_context(|| format!("exec scheduler {}", sched_bin.display()))
833}
834
835/// One scheduler+workload run.
836struct Measurement {
837    value: Option<f64>,
838    scheduler_stats: String,
839    runtime_ok: bool,
840    sched_trace: Option<Value>,
841}
842
843fn measure_once(
844    spec: &Spec,
845    sched_bin: &Path,
846    workdir: &Path,
847    run_idx: u64,
848    sudo: &Sudo,
849    errors: &mut Vec<String>,
850    progress: Option<Style>,
851    trace_enabled: bool,
852    interrupted: &AtomicBool,
853) -> Result<Measurement> {
854    let log_path = workdir.join(format!("sched.run{run_idx}.log"));
855    let mut sched = Scheduler::new(
856        sched_bin.to_path_buf(),
857        spec.scheduler.stats_interval,
858        log_path,
859    );
860
861    sched.start(sudo)?;
862    let warmup = spec.scheduler.warmup_time;
863    let mut wl_out = String::new();
864    let mut sched_trace = None;
865    let attached = wait_for_state("enabled", Duration::from_secs(warmup + 6), interrupted);
866    if attached {
867        if !sleep_interruptible(Duration::from_secs(warmup), interrupted) {
868            let duration = Duration::from_secs(spec.workload.duration);
869            let cmd = spec
870                .workload
871                .command
872                .as_deref()
873                .context("[workload].command is required")?;
874            let out_path = workdir.join(format!("wl.run{run_idx}.out"));
875            let total_runs = spec.runs();
876            let label = if total_runs > 1 {
877                format!("running workload ({}/{total_runs})...", run_idx + 1)
878            } else {
879                "running workload...".to_string()
880            };
881            let trace_recorder = if trace_enabled {
882                let trace_path = workdir.join(format!("perf.run{run_idx}.data"));
883                let trace_log_path = workdir.join(format!("perf.run{run_idx}.log"));
884                let max_trace_bytes = spec.tracing.max_trace_bytes()?;
885                match TraceRecorder::start(
886                    sudo,
887                    trace_path,
888                    trace_log_path,
889                    max_trace_bytes,
890                    &spec.tracing.max_trace_size,
891                    &spec.tracing.trace_events,
892                ) {
893                    Ok(recorder) => Some(recorder),
894                    Err(e) => {
895                        sched_trace = Some(json!({
896                            "enabled": false,
897                            "error": format!("{e:#}"),
898                        }));
899                        None
900                    }
901                }
902            } else {
903                None
904            };
905            let workload = run_capture(cmd, duration, &out_path, progress, label, interrupted);
906            if let Some(recorder) = trace_recorder {
907                sched_trace = Some(recorder.stop(sudo, &spec.tracing.trace_events));
908            }
909            wl_out = workload?;
910        }
911    } else {
912        errors.push(format!(
913            "run {run_idx}: scheduler did not attach; log tail:\n{}",
914            tail(&sched.log_text(), 2000)
915        ));
916    }
917
918    let runtime_ok = if attached {
919        let post_workload_state = scx_state();
920        if scx_is_enabled(post_workload_state.as_deref()) {
921            true
922        } else {
923            errors.push(format!(
924                "run {run_idx}: scheduler is no longer enabled after workload \
925                 (scx state={:?}); treating candidate as a regression to avoid \
926                 measuring the default scheduler; log tail:\n{}",
927                post_workload_state,
928                tail(&sched.log_text(), 2000)
929            ));
930            false
931        }
932    } else {
933        true
934    };
935
936    let final_state = sched.stop(sudo);
937    if !matches!(final_state.as_deref(), None | Some("disabled")) {
938        errors.push(format!(
939            "run {run_idx}: teardown failed, scx state={:?}",
940            final_state
941        ));
942    }
943    if !attached {
944        return Ok(Measurement {
945            value: None,
946            scheduler_stats: sched.log_text(),
947            runtime_ok,
948            sched_trace,
949        });
950    }
951    if !runtime_ok {
952        return Ok(Measurement {
953            value: None,
954            scheduler_stats: sched.log_text(),
955            runtime_ok,
956            sched_trace,
957        });
958    }
959
960    let val = extract_metric(&wl_out);
961    if val.is_none() {
962        errors.push(format!(
963            "run {run_idx}: workload command output was not a plain number"
964        ));
965    }
966    Ok(Measurement {
967        value: val,
968        scheduler_stats: sched.log_text(),
969        runtime_ok,
970        sched_trace,
971    })
972}
973
974fn tail(s: &str, n: usize) -> String {
975    if s.len() > n {
976        s[s.len() - n..].to_string()
977    } else {
978        s.to_string()
979    }
980}
981
982/// Build a non-complete verdict (build/preflight/attach/metric/spec stages).
983#[allow(clippy::too_many_arguments)]
984fn fail(
985    stage: &str,
986    spec: Option<&Spec>,
987    errors: Vec<String>,
988    extra: Vec<(&str, Value)>,
989) -> Verdict {
990    let (metric_name, goal, package) = match spec {
991        Some(s) => (
992            METRIC_NAME.to_string(),
993            s.goal.direction.clone(),
994            Some(s.scheduler.package.clone()),
995        ),
996        None => (METRIC_NAME.to_string(), "minimize".to_string(), None),
997    };
998    let mut raw = serde_json::Map::new();
999    raw.insert("ok".into(), json!(false));
1000    raw.insert("stage".into(), json!(stage));
1001    if let Some(p) = &package {
1002        raw.insert("package".into(), json!(p));
1003    }
1004    raw.insert("errors".into(), json!(errors));
1005    for (k, v) in extra {
1006        raw.insert(k.into(), v);
1007    }
1008    Verdict {
1009        ok: false,
1010        stage: stage.to_string(),
1011        metric_name,
1012        goal,
1013        value: None,
1014        median: None,
1015        stddev: None,
1016        errors,
1017        raw: Value::Object(raw),
1018    }
1019}
1020
1021/// Build the candidate, attach it, drive the workload, and return a verdict.
1022pub fn run_validation(
1023    repo_root: &Path,
1024    spec_path: &Path,
1025    sudo: &Sudo,
1026    verbose: bool,
1027    progress: Option<Style>,
1028    tracing_enabled: bool,
1029    interrupted: &AtomicBool,
1030) -> Result<Verdict> {
1031    let spec = match Spec::load(spec_path) {
1032        Ok(s) => s,
1033        Err(e) => return Ok(fail("spec", None, vec![format!("{e:#}")], vec![])),
1034    };
1035
1036    if interrupt::requested(interrupted) {
1037        return Ok(fail(
1038            "interrupted",
1039            Some(&spec),
1040            vec!["interrupted by Ctrl-C".to_string()],
1041            vec![],
1042        ));
1043    }
1044
1045    if spec.workload.command.is_none() {
1046        return Ok(fail(
1047            "spec",
1048            Some(&spec),
1049            vec![
1050                "[workload].command is required; it must run the workload and print one numeric \
1051                 metric value"
1052                    .to_string(),
1053            ],
1054            vec![],
1055        ));
1056    }
1057
1058    let package = spec.scheduler.package.clone();
1059    let profile = spec.scheduler.profile.clone();
1060
1061    // Stage 1: build (cheap correctness / verifier-source gate).
1062    if verbose {
1063        eprintln!(
1064            "  validate: {} build --profile {profile} -p {package}",
1065            cargo_program().to_string_lossy()
1066        );
1067    }
1068    let _spinner = progress.map(|style| {
1069        ProgressSpinner::stdout(
1070            format!("building scheduler {package} for validation..."),
1071            style,
1072        )
1073    });
1074    let (ok, build_out) = cargo_build(repo_root, &package, &profile, interrupted);
1075    drop(_spinner);
1076    if interrupt::requested(interrupted) {
1077        return Ok(fail(
1078            "interrupted",
1079            Some(&spec),
1080            vec!["interrupted by Ctrl-C".to_string()],
1081            vec![("stderr", json!(tail(&build_out, 4000)))],
1082        ));
1083    }
1084    if !ok {
1085        return Ok(fail(
1086            "build",
1087            Some(&spec),
1088            vec!["cargo build failed".to_string()],
1089            vec![("stderr", json!(tail(&build_out, 4000)))],
1090        ));
1091    }
1092    let sched_bin = binary_path(repo_root, &package, &profile);
1093    if !sched_bin.exists() {
1094        return Ok(fail(
1095            "build",
1096            Some(&spec),
1097            vec![format!("built binary not found at {}", sched_bin.display())],
1098            vec![],
1099        ));
1100    }
1101
1102    // Stage 2: pre-flight. Require a sched_ext-capable kernel, nothing attached,
1103    // and working sudo.
1104    match scx_state() {
1105        None => {
1106            return Ok(fail(
1107                "preflight",
1108                Some(&spec),
1109                vec![format!(
1110                    "{SCX_STATE_PATH} not found; kernel lacks sched_ext"
1111                )],
1112                vec![],
1113            ));
1114        }
1115        Some(s) if s != "disabled" => {
1116            return Ok(fail(
1117                "preflight",
1118                Some(&spec),
1119                vec![format!("a scheduler is already attached (state={s:?})")],
1120                vec![],
1121            ));
1122        }
1123        _ => {}
1124    }
1125    if let Err(e) = sudo.authenticate() {
1126        return Ok(fail(
1127            "preflight",
1128            Some(&spec),
1129            vec![
1130                "sudo authentication failed; run as root, configure passwordless \
1131                 sudo, set [system].sudo_passwd_file in the spec, or point \
1132                 SCX_SUDO_PASSWORD_FILE at a file containing the sudo password"
1133                    .to_string(),
1134                tail(&format!("{e}"), 500),
1135            ],
1136            vec![],
1137        ));
1138    }
1139
1140    let workdir = repo_root.join("target").join(format!("{package}_validate"));
1141    std::fs::create_dir_all(&workdir)
1142        .with_context(|| format!("create workdir {}", workdir.display()))?;
1143
1144    let mut errors: Vec<String> = Vec::new();
1145    let mut runtime_failed = false;
1146    let trace_enabled = tracing_enabled && perf_available();
1147    if verbose && tracing_enabled && !trace_enabled {
1148        eprintln!("  validate: perf not available; tracing disabled");
1149    }
1150
1151    // Measured runs (target scheduler attached). There is no no-scheduler
1152    // baseline: the objective is to improve the scheduler relative to its own
1153    // starting point, so running the workload under the default kernel scheduler
1154    // would just waste a run.
1155    let mut values: Vec<f64> = Vec::new();
1156    let mut last_stats = String::new();
1157    let mut trace_runs: Vec<Value> = Vec::new();
1158    for i in 0..spec.runs() {
1159        if interrupt::requested(interrupted) {
1160            break;
1161        }
1162        if verbose {
1163            eprintln!("  validate: run {}/{}", i + 1, spec.runs());
1164        }
1165        let measurement = measure_once(
1166            &spec,
1167            &sched_bin,
1168            &workdir,
1169            i,
1170            sudo,
1171            &mut errors,
1172            progress,
1173            trace_enabled,
1174            interrupted,
1175        )?;
1176        runtime_failed |= !measurement.runtime_ok;
1177        last_stats = measurement.scheduler_stats;
1178        if let Some(trace) = measurement.sched_trace {
1179            trace_runs.push(json!({
1180                "run": i,
1181                "summary": trace,
1182            }));
1183        }
1184        if let Some(v) = measurement.value {
1185            values.push(v);
1186        }
1187        if interrupt::requested(interrupted) {
1188            break;
1189        }
1190    }
1191
1192    if interrupt::requested(interrupted) {
1193        return Ok(fail(
1194            "interrupted",
1195            Some(&spec),
1196            vec!["interrupted by Ctrl-C".to_string()],
1197            vec![
1198                ("scheduler_log_tail", json!(tail(&last_stats, 2000))),
1199                ("sched_trace", json!(trace_runs)),
1200            ],
1201        ));
1202    }
1203
1204    if runtime_failed {
1205        return Ok(fail(
1206            "runtime",
1207            Some(&spec),
1208            errors,
1209            vec![
1210                ("scheduler_log_tail", json!(tail(&last_stats, 2000))),
1211                ("sched_trace", json!(trace_runs)),
1212            ],
1213        ));
1214    }
1215    if values.is_empty() && scx_state().as_deref() != Some("disabled") {
1216        return Ok(fail(
1217            "attach",
1218            Some(&spec),
1219            errors,
1220            vec![
1221                ("scheduler_log_tail", json!(tail(&last_stats, 2000))),
1222                ("sched_trace", json!(trace_runs)),
1223            ],
1224        ));
1225    }
1226    if values.is_empty() {
1227        let errs = if errors.is_empty() {
1228            vec!["no metric value extracted".to_string()]
1229        } else {
1230            errors
1231        };
1232        return Ok(fail(
1233            "metric",
1234            Some(&spec),
1235            errs,
1236            vec![
1237                ("scheduler_log_tail", json!(tail(&last_stats, 2000))),
1238                ("sched_trace", json!(trace_runs)),
1239            ],
1240        ));
1241    }
1242
1243    let med = median(&values);
1244    let std = pstdev(&values);
1245    let raw = json!({
1246        "ok": true,
1247        "stage": "complete",
1248        "package": package,
1249        "metric": {"name": METRIC_NAME, "goal": spec.goal.direction, "value": med},
1250        "runs": values,
1251        "median": med,
1252        "stddev": std,
1253        "scheduler_log_tail": tail(&last_stats, 2000),
1254        "sched_trace": trace_runs,
1255        "errors": errors,
1256    });
1257    Ok(Verdict {
1258        ok: true,
1259        stage: "complete".to_string(),
1260        metric_name: METRIC_NAME.to_string(),
1261        goal: spec.goal.direction.clone(),
1262        value: Some(med),
1263        median: Some(med),
1264        stddev: Some(std),
1265        errors,
1266        raw,
1267    })
1268}
1269
1270#[cfg(test)]
1271mod tests {
1272    use super::*;
1273
1274    #[test]
1275    fn extract_metric_parses_plain_number() {
1276        assert_eq!(extract_metric(" 42.5\n"), Some(42.5));
1277        assert_eq!(extract_metric(""), None);
1278        assert_eq!(extract_metric("value=42.5"), None);
1279        assert_eq!(extract_metric("42.5\n43.5"), None);
1280    }
1281
1282    #[test]
1283    fn trace_stats_parse_events_generically() {
1284        let mut stats = TraceStats::default();
1285        assert_eq!(
1286            trace_event("bash-10 [003] d..2. 1.000: sched_switch: prev_comm=bash prev_pid=10"),
1287            Some((1.0, "sched_switch".to_string(), Some("003".to_string())))
1288        );
1289        assert_eq!(
1290            trace_event("workload 1234 [007] 2.000000001: 100000 cache-misses:"),
1291            Some((
1292                2.000000001,
1293                "cache-misses".to_string(),
1294                Some("007".to_string())
1295            ))
1296        );
1297        update_trace_stats(
1298            &mut stats,
1299            "bash-10 [003] d..2. 1.000: sched_switch: prev_comm=bash prev_pid=10 prev_prio=120 prev_state=R ==> next_comm=schbench next_pid=11 next_prio=120",
1300        );
1301        update_trace_stats(
1302            &mut stats,
1303            "kworker-7 [001] d..2. 1.001: sched_wakeup: comm=schbench pid=11 prio=120 target_cpu=003",
1304        );
1305        update_trace_stats(
1306            &mut stats,
1307            "kworker-8 [002] d..2. 1.002: sched_wakeup_new: comm=worker pid=12 prio=120 target_cpu=004",
1308        );
1309        update_trace_stats(
1310            &mut stats,
1311            "bash-10 [003] d..2. 1.003: sched_migrate_task: comm=schbench pid=11 prio=120 orig_cpu=003 dest_cpu=005",
1312        );
1313        update_trace_stats(
1314            &mut stats,
1315            "irq/99-42 [005] d..2. 1.004: irq_handler_entry: irq=99 name=test_irq",
1316        );
1317
1318        assert_eq!(stats.total(), 5);
1319        assert_eq!(stats.events.get("sched_switch"), Some(&1));
1320        assert_eq!(stats.events.get("sched_wakeup"), Some(&1));
1321        assert_eq!(stats.events.get("sched_wakeup_new"), Some(&1));
1322        assert_eq!(stats.events.get("sched_migrate_task"), Some(&1));
1323        assert_eq!(stats.events.get("irq_handler_entry"), Some(&1));
1324        assert_eq!(stats.per_cpu_events.get("003"), Some(&2));
1325        assert_eq!(stats.start_ts, Some(1.0));
1326        assert_eq!(stats.end_ts, Some(1.004));
1327
1328        let summary = trace_stats_json(
1329            &stats,
1330            Path::new("/tmp/perf.data"),
1331            Path::new("/tmp/perf.script"),
1332            &[
1333                "sched:sched_switch".to_string(),
1334                "irq:irq_handler_entry".to_string(),
1335            ],
1336        );
1337        assert_eq!(summary["trace_window"]["start_ts"], json!(1.0));
1338        assert_eq!(summary["trace_window"]["end_ts"], json!(1.004));
1339        assert_eq!(summary["trace_window"]["duration_sec"], json!(0.004));
1340        assert_eq!(summary["event_count_total"], json!(5));
1341        assert_eq!(summary["event_rate_total_per_sec"], json!(1250.0));
1342        assert_eq!(
1343            summary["event_counts"][0],
1344            json!({"event": "irq_handler_entry", "count": 1})
1345        );
1346        assert_eq!(
1347            summary["event_rates_per_sec"][0],
1348            json!({"event": "irq_handler_entry", "per_sec": 250.0})
1349        );
1350        assert!(!summary.as_object().unwrap().contains_key("sched_switch"));
1351        assert!(!summary.as_object().unwrap().contains_key("top_switch_cpu"));
1352        assert!(
1353            !summary
1354                .as_object()
1355                .unwrap()
1356                .contains_key("top_cpu_event_rates")
1357        );
1358        assert_eq!(summary["cpu_event_rate_summary"]["cpus_observed"], json!(4));
1359        assert_eq!(
1360            summary["cpu_event_rate_summary"]["median_events_per_sec"],
1361            json!(250.0)
1362        );
1363        assert_eq!(
1364            summary["cpu_event_rate_summary"]["max_events_per_sec"],
1365            json!(500.0)
1366        );
1367        assert_eq!(
1368            summary["cpu_event_rate_summary"]["max_to_median_ratio"],
1369            json!(2.0)
1370        );
1371        assert_eq!(
1372            summary["cpu_event_rate_summary"]["top_cpus_by_total_events"][0]["cpu"],
1373            json!("003")
1374        );
1375        assert_eq!(
1376            summary["cpu_event_rate_summary"]["top_cpus_by_total_events"][0]["events"],
1377            json!(2)
1378        );
1379        assert_eq!(
1380            summary["cpu_event_rate_summary"]["top_cpus_by_total_events"][0]["events_per_sec"],
1381            json!(500.0)
1382        );
1383    }
1384
1385    #[test]
1386    fn scx_is_enabled_only_accepts_enabled_state() {
1387        assert!(scx_is_enabled(Some("enabled")));
1388        assert!(!scx_is_enabled(Some("disabled")));
1389        assert!(!scx_is_enabled(Some("enabling")));
1390        assert!(!scx_is_enabled(None));
1391    }
1392
1393    #[test]
1394    fn median_odd_even() {
1395        assert_eq!(median(&[3.0, 1.0, 2.0]), 2.0);
1396        assert_eq!(median(&[1.0, 2.0, 3.0, 4.0]), 2.5);
1397    }
1398
1399    #[test]
1400    fn pstdev_matches_population() {
1401        // pstdev([1,2,3,4,5]) == sqrt(2) == 1.4142...
1402        let s = pstdev(&[1.0, 2.0, 3.0, 4.0, 5.0]);
1403        assert!((s - 1.414_213_562).abs() < 1e-6);
1404        assert_eq!(pstdev(&[42.0]), 0.0);
1405    }
1406
1407    #[test]
1408    fn binary_path_profiles() {
1409        let r = Path::new("/repo");
1410        assert_eq!(
1411            binary_path(r, "scx_forge", "release"),
1412            Path::new("/repo/target/release/scx_forge")
1413        );
1414        assert_eq!(
1415            binary_path(r, "scx_forge", "dev"),
1416            Path::new("/repo/target/debug/scx_forge")
1417        );
1418        assert_eq!(
1419            binary_path(r, "scx_forge", "bench"),
1420            Path::new("/repo/target/bench/scx_forge")
1421        );
1422    }
1423}