1use std::fs::OpenOptions;
76use std::io::Write;
77use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
78use std::path::{Path, PathBuf};
79
80use anyhow::{Context, Result, bail};
81use base64::Engine;
82use base64::engine::general_purpose::STANDARD as BASE64;
83use flate2::Compression;
84use flate2::write::GzEncoder;
85
86use crate::test_support::{
87 KtstrTestEntry, SchedulerSpec, content_hash, find_test, resolve_scheduler, scratch_dir,
88};
89
90pub fn export_test(test_name: &str, output: Option<PathBuf>) -> Result<()> {
101 let entry = find_test(test_name)
102 .ok_or_else(|| anyhow::anyhow!("no registered test named '{test_name}'"))?;
103
104 if entry.host_only {
105 bail!(
106 "test '{test_name}' is host_only — it orchestrates cargo / nested VMs \
107 from inside the test body and cannot be reproduced outside the \
108 framework harness. host_only tests are out of scope for export."
109 );
110 }
111 if !entry.bpf_map_write.is_empty() {
112 bail!(
113 "test '{test_name}' uses bpf_map_write — runtime BPF map writes are \
114 driven by the framework's host-side probe machinery, which is not \
115 reproduced bare-metal. bpf_map_write tests are out of scope for v1 \
116 export."
117 );
118 }
119 if let SchedulerSpec::KernelBuiltin { .. } = &entry.scheduler.binary {
130 bail!(
131 "test '{test_name}' uses a KernelBuiltin scheduler — it activates via \
132 host-side shell commands (`enable` / `disable` slots) rather than a \
133 userspace binary. The export preamble does not yet emit those \
134 commands; KernelBuiltin export is out of scope for v1."
135 );
136 }
137
138 let test_binary =
139 std::env::current_exe().context("locate the current test binary via /proc/self/exe")?;
140
141 let scheduler_path = resolve_scheduler_for_export(entry)?;
142 let mut include_files = resolve_include_files(entry)?;
143 let config_additions = compute_config_export_additions(entry)
144 .context("resolve scheduler config file for export")?;
145 for addition in &config_additions {
146 include_files.push(addition.host_path.clone());
147 }
148
149 let output_path = output.unwrap_or_else(|| PathBuf::from(format!("{test_name}.run")));
150
151 let archive = build_archive(&test_binary, scheduler_path.as_deref(), &include_files)
152 .context("build embedded gzip tarball")?;
153
154 let preamble = generate_preamble(entry, scheduler_path.is_some(), &config_additions);
155
156 write_runfile(&output_path, &preamble, &archive)
157 .with_context(|| format!("write runfile to {}", output_path.display()))?;
158
159 eprintln!(
160 "wrote {} ({} bytes archive, {} include files)",
161 output_path.display(),
162 archive.len(),
163 include_files.len()
164 );
165 Ok(())
166}
167
168fn resolve_scheduler_for_export(entry: &KtstrTestEntry) -> Result<Option<PathBuf>> {
181 let (path, _source) = resolve_scheduler(&entry.scheduler.binary)
182 .with_context(|| format!("resolve scheduler binary for test '{}'", entry.name))?;
183 Ok(path)
184}
185
186#[derive(Debug)]
195struct ConfigExportAddition {
196 host_path: PathBuf,
203 args_shell_prefix: String,
213}
214
215fn compute_config_export_additions(entry: &KtstrTestEntry) -> Result<Vec<ConfigExportAddition>> {
234 let mut out = Vec::new();
235 if let Some(addition) = config_file_addition(entry)? {
236 out.push(addition);
237 }
238 if let Some(addition) = config_content_addition(entry)? {
239 out.push(addition);
240 }
241 Ok(out)
242}
243
244fn config_file_addition(entry: &KtstrTestEntry) -> Result<Option<ConfigExportAddition>> {
250 let Some(config_path) = entry.scheduler.config_file else {
251 return Ok(None);
252 };
253 let host_path = PathBuf::from(config_path);
254 if !host_path.exists() {
255 bail!(
256 "scheduler '{}' declares config_file {} but the file is not present on the host",
257 entry.scheduler.name,
258 host_path.display()
259 );
260 }
261 if host_path.is_dir() {
266 bail!(
267 "scheduler '{}' declares config_file {} but the path is a directory — \
268 config_file must point at a regular file. Recursive directory packaging \
269 is a v2 enhancement; for now, list a single file or split the directory \
270 contents across `include_files` declarations.",
271 entry.scheduler.name,
272 host_path.display()
273 );
274 }
275 let basename = host_path
276 .file_name()
277 .and_then(|n| n.to_str())
278 .ok_or_else(|| {
279 anyhow::anyhow!(
280 "scheduler config_file {} has no valid basename",
281 host_path.display()
282 )
283 })?
284 .to_string();
285 reject_shell_metacharacters_in_basename(&basename, &host_path.display().to_string())?;
286 let args_shell_prefix = format!("--config \"$DIR/include/{basename}\"");
287 Ok(Some(ConfigExportAddition {
288 host_path,
289 args_shell_prefix,
290 }))
291}
292
293fn config_content_addition(entry: &KtstrTestEntry) -> Result<Option<ConfigExportAddition>> {
307 let Some(content) = entry.config_content else {
308 return Ok(None);
309 };
310 let Some((arg_template, guest_path)) = entry.scheduler.config_file_def else {
311 return Ok(None);
312 };
313 let basename = std::path::Path::new(guest_path)
314 .file_name()
315 .and_then(|n| n.to_str())
316 .ok_or_else(|| {
317 anyhow::anyhow!(
318 "scheduler '{}' config_file_def guest_path '{}' has no valid basename",
319 entry.scheduler.name,
320 guest_path
321 )
322 })?
323 .to_string();
324 reject_shell_metacharacters_in_basename(&basename, guest_path)?;
325 let hash = content_hash(content);
326 let dir = scratch_dir();
335 let canonical = dir.join(format!("ktstr-export-config-{hash:016x}-{basename}"));
336 let mut scratch = tempfile::NamedTempFile::new_in(dir)
337 .with_context(|| "create ktstr export-config scratch file")?;
338 scratch
339 .as_file_mut()
340 .write_all(content.as_bytes())
341 .with_context(|| "write inline config_content to scratch")?;
342 scratch.persist(&canonical).with_context(|| {
343 format!(
344 "atomic-rename export-config scratch to {}",
345 canonical.display()
346 )
347 })?;
348 let runtime_path = format!("\"$DIR/include/{basename}\"");
349 let expanded = arg_template.replace("{file}", &runtime_path);
350 Ok(Some(ConfigExportAddition {
351 host_path: canonical,
352 args_shell_prefix: expanded,
353 }))
354}
355
356fn reject_shell_metacharacters_in_basename(basename: &str, source: &str) -> Result<()> {
364 for c in basename.chars() {
365 if c == '"' || c == '\\' || c == '$' || c == '`' {
366 bail!(
367 "scheduler config file basename {basename:?} (from {source}) contains shell-metacharacter {c:?}; \
368 this would break the double-quoted .run preamble interpolation. \
369 Rename the file to use only ASCII letters, digits, `_`, `-`, and `.`."
370 );
371 }
372 }
373 Ok(())
374}
375
376fn resolve_include_files(entry: &KtstrTestEntry) -> Result<Vec<PathBuf>> {
398 let mut out = Vec::new();
399 for spec in entry.all_include_files() {
400 let path = if spec.starts_with('/')
401 || spec.starts_with("./")
402 || spec.starts_with("../")
403 || spec.contains('/')
404 {
405 PathBuf::from(spec)
406 } else {
407 search_path_for(spec).ok_or_else(|| {
409 anyhow::anyhow!(
410 "include file '{spec}' not found in PATH (test \
411 declared it but the host doesn't have it; install or \
412 supply an absolute path)"
413 )
414 })?
415 };
416 if !path.exists() {
417 bail!("include file does not exist on host: {}", path.display());
418 }
419 if path.is_dir() {
424 bail!(
425 "include file '{}' is a directory — export packs regular files \
426 only. Recursive directory packaging is a v2 enhancement; for \
427 now, list each file individually in the test's \
428 `include_files` slot.",
429 path.display()
430 );
431 }
432 out.push(path);
433 }
434 Ok(out)
435}
436
437fn search_path_for(name: &str) -> Option<PathBuf> {
450 let path_var = std::env::var_os("PATH")?;
451 for dir in std::env::split_paths(&path_var) {
452 let candidate = dir.join(name);
453 if !candidate.is_file() {
454 continue;
455 }
456 let executable = candidate
457 .metadata()
458 .map(|m| m.permissions().mode() & 0o111 != 0)
459 .unwrap_or(false);
460 if executable {
461 return Some(candidate);
462 }
463 }
464 None
465}
466
467fn build_archive(ktstr: &Path, scheduler: Option<&Path>, includes: &[PathBuf]) -> Result<Vec<u8>> {
480 let buf: Vec<u8> = Vec::new();
481 let gz = GzEncoder::new(buf, Compression::default());
482 let mut tar = tar::Builder::new(gz);
483
484 append_file(&mut tar, ktstr, "ktstr")?;
485 if let Some(s) = scheduler {
486 append_file(&mut tar, s, "scheduler")?;
487 }
488
489 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
490 for inc in includes {
491 let name = inc
492 .file_name()
493 .ok_or_else(|| anyhow::anyhow!("include file has no basename: {}", inc.display()))?
494 .to_string_lossy()
495 .into_owned();
496 if !seen.insert(name.clone()) {
497 bail!(
498 "include-file basename collision: two specs both flatten to \
499 'include/{name}'. Rename one or use distinct paths."
500 );
501 }
502 let archive_name = format!("include/{name}");
503 append_file(&mut tar, inc, &archive_name)?;
504 }
505
506 let gz = tar.into_inner().context("finalise tar stream")?;
507 let blob = gz.finish().context("finalise gzip stream")?;
508 Ok(blob)
509}
510
511fn append_file<W: Write>(
517 tar: &mut tar::Builder<W>,
518 host_path: &Path,
519 archive_name: &str,
520) -> Result<()> {
521 let bytes = std::fs::read(host_path)
522 .with_context(|| format!("read {} for archive", host_path.display()))?;
523 crate::tar_util::pack_tar_entry(
524 tar,
525 archive_name,
526 0o755,
527 bytes.len() as u64,
528 bytes.as_slice(),
529 )
530 .with_context(|| format!("append {archive_name} to tar"))?;
531 Ok(())
532}
533
534fn generate_preamble(
546 entry: &KtstrTestEntry,
547 has_scheduler: bool,
548 config_additions: &[ConfigExportAddition],
549) -> String {
550 let topology = entry.topology;
551 let need_llcs = topology.llcs;
552 let need_cores = topology.cores_per_llc;
553 let need_threads = topology.threads_per_core;
554 let need_numa = topology.numa_nodes;
555
556 let sched_args_joined = compose_sched_args_joined(entry, config_additions);
557
558 let test_name = shell_quote(entry.name);
566 let scheduler_name = shell_quote(entry.scheduler.name);
567 let git_hash = shell_quote(&git_provenance());
568
569 let duration_secs = entry.duration.as_secs();
570 let watchdog_secs = entry.watchdog_timeout.as_secs();
571
572 let scheduler_launch = scheduler_launch_block(has_scheduler, &sched_args_joined);
573
574 let mut script = script_header_and_spec(
575 &test_name,
576 &scheduler_name,
577 &git_hash,
578 need_llcs,
579 need_cores,
580 need_threads,
581 need_numa,
582 duration_secs,
583 watchdog_secs,
584 );
585 script.push_str(&script_arg_parser_and_help());
586 script.push_str(&script_prereq_and_topology_checks());
587 script.push_str(&script_dispatch(&scheduler_launch));
588 script
589}
590
591fn compose_sched_args_joined(
595 entry: &KtstrTestEntry,
596 config_additions: &[ConfigExportAddition],
597) -> String {
598 let mut sched_arg_tokens_raw: Vec<String> = Vec::new();
628 crate::test_support::append_base_sched_args(entry, &mut sched_arg_tokens_raw);
629 let base_joined: String = sched_arg_tokens_raw
630 .iter()
631 .map(|a| shell_quote(a))
632 .collect::<Vec<_>>()
633 .join(" ");
634 let mut sched_args_joined = String::new();
635 for addition in config_additions {
636 if !sched_args_joined.is_empty() {
637 sched_args_joined.push(' ');
638 }
639 sched_args_joined.push_str(&addition.args_shell_prefix);
640 }
641 if !base_joined.is_empty() {
642 if !sched_args_joined.is_empty() {
643 sched_args_joined.push(' ');
644 }
645 sched_args_joined.push_str(&base_joined);
646 }
647 sched_args_joined
648}
649
650fn scheduler_launch_block(has_scheduler: bool, sched_args_joined: &str) -> String {
654 if has_scheduler {
655 format!(
656 r#"
657# --- scheduler launch ---
658echo "ktstr export: launching scheduler $KTSTR_SCHED_NAME"
659"$DIR/scheduler" {sched_args_joined} &
660SCHED_PID=$!
661
662# Wait up to 10s for the scheduler to attach. The kernel's sysfs
663# layout exposes attach state under two files; both are accepted
664# so the wait loop works on every kernel that ships sched_ext:
665# - `/sys/kernel/sched_ext/root/ops` — non-empty when a scheduler
666# is currently attached. Present on every kernel revision that
667# has sched_ext, but the path moved structurally between early
668# 6.x revisions and the upstream-stabilized layout. Treat the
669# file's absence as "no scheduler attached" rather than an
670# error; the secondary check below catches stabilized kernels.
671# - `/sys/kernel/sched_ext/state` (introduced upstream in 6.12)
672# reads `enabled` once a scheduler attaches, `disabled`
673# otherwise. Use as the primary signal where available; it has
674# a stable wire format across kernel versions.
675# Bail if the scheduler exits before attaching, or if the timeout
676# elapses while the scheduler is still alive but unattached.
677ATTACHED=""
678for _ in $(seq 1 100); do
679 if ! kill -0 "$SCHED_PID" 2>/dev/null; then
680 echo "error: scheduler $KTSTR_SCHED_NAME exited before attaching" >&2
681 wait "$SCHED_PID" || true
682 exit 1
683 fi
684 if [ -r /sys/kernel/sched_ext/state ]; then
685 STATE=$(cat /sys/kernel/sched_ext/state 2>/dev/null || true)
686 if [ "$STATE" = "enabled" ]; then
687 ATTACHED="$STATE"
688 break
689 fi
690 fi
691 if [ -f /sys/kernel/sched_ext/root/ops ]; then
692 OPS=$(cat /sys/kernel/sched_ext/root/ops 2>/dev/null || true)
693 if [ -n "$OPS" ]; then
694 ATTACHED="$OPS"
695 break
696 fi
697 fi
698 sleep 0.1
699done
700if [ -z "$ATTACHED" ]; then
701 echo "error: scheduler $KTSTR_SCHED_NAME launched but did not attach within 10s" >&2
702 echo " (process is still alive; check kernel log for BPF verifier or load errors)" >&2
703 exit 1
704fi
705"#
706 )
707 } else {
708 String::new()
710 }
711}
712
713#[allow(clippy::too_many_arguments)]
717fn script_header_and_spec(
718 test_name: &str,
719 scheduler_name: &str,
720 git_hash: &str,
721 need_llcs: u32,
722 need_cores: u32,
723 need_threads: u32,
724 need_numa: u32,
725 duration_secs: u64,
726 watchdog_secs: u64,
727) -> String {
728 format!(
729 r#"#!/bin/bash
730# Generated by `cargo ktstr export`. Do not edit; regenerate to update.
731set -euo pipefail
732
733# --- frozen test specification ---
734KTSTR_TEST_NAME={test_name}
735KTSTR_SCHED_NAME={scheduler_name}
736KTSTR_GIT_HASH={git_hash}
737NEED_LLCS={need_llcs}
738NEED_CORES_PER_LLC={need_cores}
739NEED_THREADS_PER_CORE={need_threads}
740NEED_NUMA_NODES={need_numa}
741TEST_DURATION_SECS={duration_secs}
742TEST_WATCHDOG_SECS={watchdog_secs}
743"#
744 )
745}
746
747fn script_arg_parser_and_help() -> String {
752 String::from(
753 r#"
754QUIET=0
755DURATION_OVERRIDE=""
756WATCHDOG_OVERRIDE=""
757while [ $# -gt 0 ]; do
758 case "$1" in
759 --quiet) QUIET=1; shift ;;
760 --duration) DURATION_OVERRIDE="$2"; shift 2 ;;
761 --watchdog-timeout) WATCHDOG_OVERRIDE="$2"; shift 2 ;;
762 --cpus|--topology|--affinity)
763 echo "error: --$1 is frozen for repro fidelity. Re-export to change." >&2
764 exit 1 ;;
765 -h|--help)
766 cat <<EOF
767Usage: $0 [--quiet] [--duration SECS] [--watchdog-timeout SECS]
768
769Reproduces ktstr test '$KTSTR_TEST_NAME' on bare metal. The script
770extracts an embedded gzip tarball containing the ktstr binary and
771the scheduler binary, then dispatches the test directly without
772booting a VM.
773
774Frozen (cannot be overridden):
775 scheduler $KTSTR_SCHED_NAME
776 topology $NEED_NUMA_NODES NUMA / $NEED_LLCS LLCs / $NEED_CORES_PER_LLC cores/LLC / $NEED_THREADS_PER_CORE threads/core
777 scheduler args (compiled into the script)
778 --cpus, --topology, --affinity reject any override
779
780Overridable:
781 --duration SECS workload duration (default $TEST_DURATION_SECS)
782 --watchdog-timeout SECS scheduler watchdog (default $TEST_WATCHDOG_SECS)
783 --quiet suppress the banner (errors still print)
784
785Requirements:
786 Run as root. The script attaches a kernel BPF scheduler and sets
787 up cgroup v2 subgroups; both need CAP_SYS_ADMIN.
788
789 Host must satisfy the frozen topology (LLCs, cores per LLC,
790 threads per core, NUMA nodes); the script's topology check bails
791 with a specific "host has X, test needs Y" message if not.
792
793 /sys/kernel/sched_ext must exist (kernel built with
794 CONFIG_SCHED_CLASS_EXT) and no other sched_ext scheduler may be
795 attached.
796
797Exit codes:
798 0 test passed
799 1 prerequisite or topology check failed, scheduler attach
800 failed, or test failed
801EOF
802 exit 0 ;;
803 *) echo "error: unknown arg '$1' (use --help)" >&2; exit 1 ;;
804 esac
805done
806
807if [ "$QUIET" != "1" ]; then
808 cat <<EOF
809ktstr export: test=$KTSTR_TEST_NAME scheduler=$KTSTR_SCHED_NAME git=$KTSTR_GIT_HASH
810Generated by cargo ktstr export. This script attaches a kernel BPF scheduler
811and runs as root. Inspect this script (everything before __ARCHIVE__) before
812running on a system you do not control.
813EOF
814fi
815
816# --- root check ---
817if [ "$(id -u)" != "0" ]; then
818 echo "error: must run as root (need CAP_SYS_ADMIN for sched_ext + cgroup ops)" >&2
819 exit 1
820fi
821"#,
822 )
823}
824
825fn script_prereq_and_topology_checks() -> String {
830 String::from(
831 r#"
832# --- prereq checks ---
833if [ ! -d /sys/kernel/sched_ext ]; then
834 echo "error: kernel lacks sched_ext support (no /sys/kernel/sched_ext)" >&2
835 exit 1
836fi
837if [ ! -d /sys/fs/cgroup ]; then
838 echo "error: cgroup2 not mounted at /sys/fs/cgroup" >&2
839 exit 1
840fi
841if ! grep -q '^cgroup2 /sys/fs/cgroup ' /proc/mounts; then
842 echo "error: /sys/fs/cgroup is not a cgroup2 mount" >&2
843 exit 1
844fi
845
846# --- sched_ext conflict check ---
847# Mirror the attach-detection logic below: prefer
848# /sys/kernel/sched_ext/state (stabilized in 6.12) when readable,
849# fall back to /sys/kernel/sched_ext/root/ops otherwise. Either
850# file reporting an attached scheduler aborts here so we don't
851# silently displace someone else's running scheduler.
852if [ -r /sys/kernel/sched_ext/state ]; then
853 CURRENT_STATE=$(cat /sys/kernel/sched_ext/state 2>/dev/null || true)
854 if [ "$CURRENT_STATE" = "enabled" ]; then
855 CURRENT_OPS=""
856 if [ -f /sys/kernel/sched_ext/root/ops ]; then
857 CURRENT_OPS=$(cat /sys/kernel/sched_ext/root/ops 2>/dev/null || true)
858 fi
859 echo "error: another sched_ext scheduler is already attached (state=enabled, ops=${CURRENT_OPS:-unknown})." >&2
860 echo " Detach it before running this repro (e.g. kill its supervisor)." >&2
861 exit 1
862 fi
863elif [ -f /sys/kernel/sched_ext/root/ops ]; then
864 CURRENT=$(cat /sys/kernel/sched_ext/root/ops 2>/dev/null || true)
865 if [ -n "$CURRENT" ]; then
866 echo "error: another sched_ext scheduler '$CURRENT' is already attached." >&2
867 echo " Detach it before running this repro (e.g. kill its supervisor)." >&2
868 exit 1
869 fi
870fi
871
872# --- topology check ---
873# LLC count: find the highest cache-index level under cpu0 (index3
874# on most x86, but skylake-x has a dedicated L4 at index4 and ARM
875# machines vary). Sum distinct shared_cpu_lists at that level.
876HIGHEST_INDEX=$(ls -d /sys/devices/system/cpu/cpu0/cache/index* 2>/dev/null \
877 | sort -V | tail -n1 || true)
878if [ -n "$HIGHEST_INDEX" ]; then
879 HIGHEST_LEVEL=$(basename "$HIGHEST_INDEX")
880 HOST_LLCS=$(ls -d /sys/devices/system/cpu/cpu*/cache/$HIGHEST_LEVEL 2>/dev/null \
881 | xargs -I{} cat {}/shared_cpu_list 2>/dev/null \
882 | sort -u | wc -l)
883else
884 HOST_LLCS=0
885fi
886HOST_NUMA=$(ls -d /sys/devices/system/node/node* 2>/dev/null | wc -l || echo 0)
887[ "$HOST_NUMA" -lt 1 ] && HOST_NUMA=1
888
889# Cores per LLC: count distinct core_id values among cpus that share
890# the highest-level cache with cpu0. threads per core: count cpus
891# that share the same core_id within one LLC.
892if [ -n "$HIGHEST_INDEX" ]; then
893 CPU0_LLC=$(cat "$HIGHEST_INDEX/shared_cpu_list" 2>/dev/null || echo "")
894else
895 CPU0_LLC=""
896fi
897HOST_CORES_PER_LLC=0
898HOST_THREADS_PER_CORE=0
899if [ -n "$CPU0_LLC" ]; then
900 # Expand cpu list ranges (e.g. "0-3,8-11") into individual ids.
901 CPU_IDS=$(echo "$CPU0_LLC" | tr ',' '\n' | while read range; do
902 if [ -z "$range" ]; then continue; fi
903 if echo "$range" | grep -q '-'; then
904 start=$(echo "$range" | cut -d- -f1)
905 end=$(echo "$range" | cut -d- -f2)
906 seq "$start" "$end"
907 else
908 echo "$range"
909 fi
910 done)
911 HOST_CORES_PER_LLC=$(for id in $CPU_IDS; do
912 cat "/sys/devices/system/cpu/cpu$id/topology/core_id" 2>/dev/null || echo
913 done | sort -u | wc -l)
914 CPU0_CORE=$(cat /sys/devices/system/cpu/cpu0/topology/core_id 2>/dev/null || echo)
915 if [ -n "$CPU0_CORE" ]; then
916 HOST_THREADS_PER_CORE=$(for id in $CPU_IDS; do
917 this_core=$(cat "/sys/devices/system/cpu/cpu$id/topology/core_id" 2>/dev/null || echo)
918 if [ "$this_core" = "$CPU0_CORE" ]; then echo "$id"; fi
919 done | wc -l)
920 fi
921fi
922
923if [ "$HOST_LLCS" = "0" ]; then
924 echo "warning: could not detect host LLC count from sysfs (no cache/index* found for cpu0); the topology check below will fail" >&2
925fi
926if [ "$HOST_LLCS" -lt "$NEED_LLCS" ]; then
927 echo "error: host has $HOST_LLCS LLCs, test needs $NEED_LLCS" >&2
928 exit 1
929fi
930if [ "$HOST_NUMA" -lt "$NEED_NUMA_NODES" ]; then
931 echo "error: host has $HOST_NUMA NUMA nodes, test needs $NEED_NUMA_NODES" >&2
932 exit 1
933fi
934if [ "$HOST_CORES_PER_LLC" -gt 0 ] && [ "$HOST_CORES_PER_LLC" -lt "$NEED_CORES_PER_LLC" ]; then
935 echo "error: host has $HOST_CORES_PER_LLC cores per LLC, test needs $NEED_CORES_PER_LLC" >&2
936 exit 1
937fi
938if [ "$HOST_THREADS_PER_CORE" -gt 0 ] && [ "$HOST_THREADS_PER_CORE" -lt "$NEED_THREADS_PER_CORE" ]; then
939 echo "error: host has $HOST_THREADS_PER_CORE threads per core, test needs $NEED_THREADS_PER_CORE" >&2
940 exit 1
941fi
942"#,
943 )
944}
945
946fn script_dispatch(scheduler_launch: &str) -> String {
952 format!(
953 r#"
954# --- extract embedded archive ---
955DIR=$(mktemp -d -t ktstr-export-XXXXXX)
956chmod 700 "$DIR"
957# The ktstr in-process dispatch creates its cgroup tree under
958# /sys/fs/cgroup/ktstr — the export-relevant path goes through the
959# ctor early-dispatch into `test_support::probe::build_dispatch_ctx_parts`
960# which calls `test_support::args::resolve_cgroup_root` (fn at
961# args.rs:336, `/sys/fs/cgroup/ktstr` fallback at args.rs:377), and
962# the in-VM init follows the same convention.
963# Capture the path here so the trap teardown can clean any subgroups
964# the dispatch created. The rmdir must walk depth-first because
965# cgroup v2 forbids rmdir on a subtree that still contains child
966# groups.
967#
968# WARNING: this cleanup removes ALL subgroups under
969# /sys/fs/cgroup/ktstr, including those created by concurrent
970# ktstr processes. Do not run multiple ktstr workloads on the same
971# host simultaneously.
972KTSTR_CGROUP_PARENT="/sys/fs/cgroup/ktstr"
973SCHED_PID=""
974cleanup() {{
975 if [ -n "$SCHED_PID" ]; then
976 kill "$SCHED_PID" 2>/dev/null || true
977 wait "$SCHED_PID" 2>/dev/null || true
978 fi
979 rm -rf "$DIR"
980 # Cgroup teardown: depth-first rmdir over every subgroup the
981 # test created. cgroup v2's interface files (cgroup.procs,
982 # cgroup.controllers, ...) are auto-removed when their parent
983 # directory rmdirs, so a recursive `rm -rf` is wrong (would
984 # ENOTEMPTY on every interior node). `find -depth` visits
985 # leaves before parents; rmdir succeeds at each step because
986 # children are gone. Errors swallowed via `2>/dev/null` so a
987 # cleanup race with another tool doesn't bleed into the test
988 # exit status.
989 if [ -d "$KTSTR_CGROUP_PARENT" ]; then
990 find "$KTSTR_CGROUP_PARENT" -mindepth 1 -depth -type d \
991 -exec rmdir {{}} + 2>/dev/null || true
992 rmdir "$KTSTR_CGROUP_PARENT" 2>/dev/null || true
993 fi
994}}
995trap cleanup EXIT
996
997# Decode embedded base64 archive (everything after __ARCHIVE__).
998sed -n '/^__ARCHIVE__$/,$p' "$0" | tail -n+2 | base64 -d | tar xz -C "$DIR"
999
1000if [ ! -x "$DIR/ktstr" ]; then
1001 echo "error: extracted ktstr binary missing or not executable" >&2
1002 exit 1
1003fi
1004{scheduler_launch}
1005# --- run the test ---
1006# `--ktstr-test-fn $KTSTR_TEST_NAME` is intercepted by the ktstr
1007# binary's `#[ctor(unsafe)] ktstr_test_early_dispatch` (in
1008# `src/test_support/dispatch.rs`), which fires from `.init_array`
1009# BEFORE `main()` runs. The ctor reads the argv directly via
1010# `extract_test_fn_arg` and dispatches via
1011# `maybe_dispatch_vm_test_with_args` (in
1012# `src/test_support/probe.rs`) which calls `(entry.func)(&ctx)`
1013# directly, then exits the process on completion. The leading
1014# `"run"` token is cosmetic — it's never parsed because the ctor
1015# exits before clap sees it. This early-dispatch path is the
1016# contract for in-process repro and is load-bearing: a future
1017# refactor that moves dispatch out of the ctor must keep an
1018# equivalent argv-intercept path in place, or this preamble must
1019# change to match the new dispatch shape.
1020#
1021# IMPORTANT: do NOT use `exec` here. `exec` replaces the bash
1022# shell with the ktstr binary and DESTROYS the EXIT trap before
1023# it can fire — leaking the scheduler PID, the tempdir, and the
1024# cgroup tree. Run as a child and forward the exit code so the
1025# trap fires on bash exit.
1026RUN_ARGS=("run" "--ktstr-test-fn" "$KTSTR_TEST_NAME")
1027if [ -n "$DURATION_OVERRIDE" ]; then
1028 RUN_ARGS+=("--duration" "$DURATION_OVERRIDE")
1029fi
1030if [ -n "$WATCHDOG_OVERRIDE" ]; then
1031 RUN_ARGS+=("--watchdog-timeout" "$WATCHDOG_OVERRIDE")
1032fi
1033# Disable errexit just for the ktstr invocation so a non-zero
1034# exit from the test (the legitimate "test failed" outcome)
1035# propagates as our exit code instead of triggering set -e and
1036# bypassing the cleanup. The `|| true` would also keep going,
1037# but `set +e` makes the intent explicit.
1038set +e
1039"$DIR/ktstr" "${{RUN_ARGS[@]}}"
1040EXIT_CODE=$?
1041set -e
1042exit $EXIT_CODE
1043"#
1044 )
1045}
1046
1047fn git_provenance() -> String {
1059 std::env::current_dir()
1060 .ok()
1061 .and_then(|cwd| gix::discover(&cwd).ok())
1062 .and_then(|repo| {
1063 repo.head_id()
1069 .ok()
1070 .map(|id| format!("{id}").chars().take(7).collect::<String>())
1071 })
1072 .unwrap_or_else(|| "unknown".to_string())
1073}
1074
1075fn shell_quote(s: &str) -> String {
1085 if s.is_empty() {
1086 return "''".to_string();
1087 }
1088 if !s.contains('\'')
1089 && s.chars()
1090 .all(|c| c.is_ascii_alphanumeric() || "._-+=/:".contains(c))
1091 {
1092 return s.to_string();
1093 }
1094 let mut out = String::with_capacity(s.len() + 2);
1095 out.push('\'');
1096 for c in s.chars() {
1097 if c == '\'' {
1098 out.push_str("'\\''");
1099 } else {
1100 out.push(c);
1101 }
1102 }
1103 out.push('\'');
1104 out
1105}
1106
1107fn write_runfile(path: &Path, preamble: &str, archive: &[u8]) -> Result<()> {
1111 let mut f = OpenOptions::new()
1112 .write(true)
1113 .create(true)
1114 .truncate(true)
1115 .mode(0o755)
1116 .open(path)
1117 .with_context(|| format!("open {} for write", path.display()))?;
1118
1119 f.write_all(preamble.as_bytes()).context("write preamble")?;
1120 f.write_all(b"__ARCHIVE__\n")
1121 .context("write archive marker")?;
1122
1123 let encoded = BASE64.encode(archive);
1124 for chunk in encoded.as_bytes().chunks(76) {
1127 f.write_all(chunk).context("write base64 chunk")?;
1128 f.write_all(b"\n").context("write newline")?;
1129 }
1130 f.sync_all().context("fsync runfile")?;
1131 drop(f);
1132 let mut perms = std::fs::metadata(path)
1133 .with_context(|| format!("stat {}", path.display()))?
1134 .permissions();
1135 perms.set_mode(0o755);
1136 std::fs::set_permissions(path, perms)
1137 .with_context(|| format!("chmod 755 {}", path.display()))?;
1138 Ok(())
1139}
1140
1141#[cfg(test)]
1142#[path = "export_tests.rs"]
1143mod tests;