ktstr_macros/lib.rs
1use proc_macro::TokenStream;
2use quote::quote;
3use syn::{DeriveInput, parse_macro_input};
4
5#[allow(dead_code)]
6mod kernel_path;
7
8mod claim;
9mod common;
10mod json;
11mod ktstr_test;
12mod payload;
13mod scheduler;
14
15/// Attribute macro that registers a function as a ktstr integration test.
16///
17/// The annotated function must have signature `fn(&ktstr::scenario::Ctx) ->
18/// anyhow::Result<ktstr::assert::AssertResult>`. The macro:
19///
20/// 1. Renames the original function to `__ktstr_inner_{name}`.
21/// 2. Registers it in the `KTSTR_TESTS` distributed slice via linkme.
22/// 3. Emits a `#[test]` wrapper that boots a VM and runs the function
23/// inside it.
24///
25/// Every attribute is optional. Most take a `key = value` form; the
26/// sixteen boolean attributes (`auto_repro`, `expect_auto_repro`,
27/// `not_starved`, `isolation`, `performance_mode`, `pci`, `no_perf_mode`,
28/// `requires_smt`, `expect_err`, `survives_storm`, `allow_inconclusive`,
29/// `fail_on_stall`, `host_only`, `ignore`, `kaslr`, `wprof`) also accept a
30/// bare form as shorthand for `= true` — e.g.
31/// `#[ktstr_test(host_only)]` is equivalent to
32/// `#[ktstr_test(host_only = true)]`. Of the sixteen, `auto_repro`
33/// and `kaslr` are the two whose default is `true`, so the bare form
34/// is a no-op; `auto_repro = false` / `kaslr = false` are the only
35/// way to disable each. The other fourteen default to `false`, so the
36/// bare form is the meaningful shorthand.
37///
38/// The accepted attributes and their defaults are the fields of
39/// `ktstr::test_support::KtstrTestEntry` (runtime metadata) and
40/// `ktstr::assert::Assert` (checking thresholds). A few are
41/// worth calling out because their names differ from the underlying
42/// field or because they have nontrivial defaults:
43///
44/// - `llcs = N` — number of LLCs (default: inherited from
45/// scheduler, or 1).
46/// - `cores = N` (default: inherited from scheduler, or 2)
47/// - `threads = N` (default: inherited from scheduler, or 1)
48/// - `numa_nodes = N` (default: inherited from scheduler, or 1)
49/// - `memory_mib = N` — per-test minimum memory in MiB (default:
50/// 2048). The framework picks `max(total_cpus * 64, 256,
51/// memory_mib)` MiB at VM-launch time, so for tests with more
52/// than 32 vCPUs the cpu-based floor dominates the macro
53/// default. Below ~4 vCPUs the absolute 256-MiB floor wins if
54/// `memory_mib` is also below it. Setting `memory_mib` above
55/// the cpu-based floor is only meaningful when the test needs
56/// more headroom than the per-cpu budget. The unit is binary
57/// mebibytes; the conversion at VM-launch is `value << 20`
58/// bytes, not decimal megabytes.
59/// - `duration_s = N` — scenario run duration in seconds; maps
60/// onto `KtstrTestEntry::duration`
61/// - `watchdog_timeout_s = N` — watchdog fire threshold in
62/// seconds; maps onto `KtstrTestEntry::watchdog_timeout`
63/// - `cleanup_budget_ms = N` — sub-watchdog cap on host-side VM
64/// teardown wall time; maps onto `KtstrTestEntry::cleanup_budget`
65/// as `Duration::from_millis(N)`. Default: `None` (unenforced).
66/// - `num_snapshots = N` — fire `N` periodic
67/// `freeze_and_dispatch(FreezeMode::Capture { gate_on_exit_kind: false })` boundaries inside the workload's
68/// 10 %–90 % window, stored on the host
69/// `SnapshotBridge` under `periodic_NNN`. `0` (default)
70/// disables periodic capture entirely. Maps onto
71/// `KtstrTestEntry::num_snapshots`; runtime
72/// `KtstrTestEntry::validate` rejects values past the bridge
73/// cap (`MAX_STORED_SNAPSHOTS`), `host_only = true`, and
74/// duration / `N` settings that would land boundaries closer
75/// than 100 ms apart.
76/// - `scheduler = PATH` — path to a `const Scheduler` (typically
77/// produced by `declare_scheduler!(...)`). Maps onto
78/// `KtstrTestEntry::scheduler`, which is typed
79/// `&'static Scheduler`. Default: `&Scheduler::EEVDF`, the
80/// no-scx placeholder that runs under the kernel's default
81/// scheduler.
82/// - `payload = PATH` — path to a `const Payload` used as the
83/// primary binary workload (must be `PayloadKind::Binary`;
84/// runtime-enforced). Default: `None` (scheduler-only test).
85/// Coexists with `scheduler = PATH` — the payload runs *under*
86/// the selected scheduler.
87/// - `workloads = [PATH, PATH, ...]` — additional `const Payload`
88/// references composed with the primary via `Ctx::payload` in
89/// the test body. Default: `&[]`. Must not contain the same
90/// path as `payload` — reject at expansion time to catch the
91/// common "fio as primary AND workload" slip.
92/// - `auto_repro = bool` (default: `true`)
93/// - `wprof = bool` (default: `false`; requires the `wprof`
94/// cargo feature on ktstr) — attach `/bin/wprof` to the
95/// test's VM(s) and ship the Perfetto trace to the host.
96/// - `wprof_args = "..."` (requires the `wprof` cargo feature)
97/// — override `WprofConfig::default_args`. Only meaningful
98/// with `wprof = true`. Parsed as space-separated tokens.
99/// - `host_only = bool` (default: `false`) — run the test function
100/// on the host instead of inside a VM
101/// - `no_perf_mode = bool` (default: `false`) — decouple the
102/// virtual topology from host hardware. The VM is built with
103/// the declared `numa_nodes` / `llcs` / `cores` / `threads`
104/// even on smaller hosts; vCPU pinning, hugepages, NUMA mbind,
105/// RT scheduling, and KVM exit suppression are skipped, and
106/// gauntlet preset filtering relaxes host-topology checks
107/// to the single "host has enough total CPUs" inequality.
108/// Mutually exclusive with `performance_mode = true`. Maps onto
109/// `KtstrTestEntry::no_perf_mode`.
110/// - `post_vm = PATH` — host-side callback invoked after
111/// `vm.run()` returns, with access to the full `VmResult`.
112/// Use for assertions that need host-side state — e.g.
113/// draining `VmResult.snapshot_bridge` after a snapshot
114/// capture pipeline fires inside the guest. The function
115/// must have signature
116/// `fn(&ktstr::vmm::VmResult) -> anyhow::Result<()>`. PATH
117/// accepts any Rust path-expression that resolves to a value
118/// of that fn type — both free-function refs
119/// (`my_post_vm_check`) AND UFCS method refs
120/// (`VmResult::assert_wprof_pb_landed`) work via Rust's
121/// function-item-to-fn-pointer coercion, so a method that
122/// already has the right `&self -> Result<()>` shape can be
123/// pointed at directly without wrapping in a one-line
124/// delegating free fn.
125/// SUPPRESSED on guest-reported fail — see
126/// `post_vm_unconditional` for the always-runs sibling.
127/// Default: `None` (no callback).
128/// - `post_vm_unconditional = PATH` — host-side callback that
129/// always runs after `vm.run()` returns, bypassing the
130/// guest-fail suppression that gates `post_vm`. Same
131/// signature as `post_vm` and PATH accepts the same form:
132/// any Rust path-expression resolving to a value of that fn
133/// type — both free-function refs AND UFCS method refs
134/// (`VmResult::assert_wprof_pb_landed`-style) work via
135/// function-item-to-fn-pointer coercion. Use when the
136/// callback must observe host-side state regardless of
137/// guest-side outcome (e.g. verifying a sidecar artifact
138/// landed even when the guest reported a deliberate fail).
139/// The callback is responsible for guarding against missing
140/// state when the scheduler crashed before producing it —
141/// the canonical guard is
142/// `if !result.success { return Ok(()); }` at the top of the
143/// callback body. Setting `post_vm_unconditional` does NOT
144/// invert the test verdict — a guest-reported fail still
145/// fails the test even if the unconditional callback returns
146/// Ok. Both attributes may be set on the same entry (both
147/// errors surface via `combine_post_vm_errs` when both
148/// fire). Default: `None` (no callback).
149/// - `disk = PATH` — path to a `const DiskConfig` attached to the
150/// VM as a virtio-blk device at `/dev/vda`. Construct via
151/// `DiskConfig::DEFAULT.with_name("data")` or similar const-fn
152/// chain (the `with_name` builder takes `&'static str` so the
153/// full expression is const-evaluable). Maps onto
154/// `KtstrTestEntry::disk`. Default: `None` (no disk).
155/// Mutually exclusive with `host_only = true` —
156/// `host_only` skips the VM boot that owns the device lifecycle,
157/// so a `disk` attached under `host_only` would never bind;
158/// `KtstrTestEntry::validate` rejects the pairing at runtime.
159/// - `networks = [PATH, ...]` — array of `const NetConfig` paths, one
160/// virtio-net device per element (in-VMM loopback backend). On x86_64
161/// each lands on its own virtio-pci function (PCI slots 1..=N, one INTx
162/// GSI apiece); aarch64 takes a single virtio-MMIO NIC (build() errors
163/// on more than one). Construct
164/// each via `NetConfig::DEFAULT.mac(...)` or `NetConfig::DEFAULT`
165/// (const-fn chain). Maps onto `KtstrTestEntry::networks`. Default: `[]`
166/// (no NIC). Like `disk`, mutually exclusive with `host_only`.
167/// - `config = EXPR` — inline scheduler config content, written
168/// into the guest at the path declared by the scheduler's
169/// `config_file_def`. `EXPR` is either a string literal or a
170/// path to a `const &'static str` (e.g. `LAYERED_CONFIG`).
171/// Maps onto `KtstrTestEntry::config_content`. Required when
172/// the scheduler declares `config_file_def`; rejected when the
173/// scheduler does not. The pairing is enforced at compile time
174/// via a `const` assertion against `Scheduler::config_file_def`,
175/// and again at runtime by `KtstrTestEntry::validate` so direct
176/// programmatic-entry construction sees the same gate.
177/// - `expect_scx_bpf_error_contains = EXPR` — literal-substring
178/// matcher applied to the captured `scx_bpf_error` text in
179/// reproducer mode. `EXPR` is either a string literal or a path
180/// to a `const &'static str`. Maps onto
181/// `Assert::expect_scx_bpf_error_contains`. Requires
182/// `expect_err = true` (rejected at construction otherwise by
183/// `KtstrTestEntry::validate`). Empty strings panic at
184/// construction. When both `_contains` and `_matches` are set,
185/// the evaluator ANDs them — every set matcher must hit.
186/// - `expect_scx_bpf_error_matches = EXPR` — regex matcher with
187/// the same accepted forms and gating as `_contains`. Maps onto
188/// `Assert::expect_scx_bpf_error_matches`. Validated at
189/// construction: empty patterns, invalid regex syntax, and any
190/// pattern satisfying `is_match("")` all panic immediately. The
191/// `is_match("")` predicate catches two no-op classes with one
192/// check: patterns that match every position (e.g. `a?`, `.*`,
193/// `(?:)`) trivially pass against any corpus, and patterns that
194/// match only the empty string (e.g. `^$`) trivially fail
195/// against any non-empty corpus — both are equally useless pins.
196/// Bare `\b` slips the gate (no word characters in `""`); see
197/// `Assert::expect_scx_bpf_error_matches` for the operator
198/// direction.
199/// - `survives_storm` — assert the scx scheduler SURVIVES the run
200/// (does not die or get ejected during any hold); the positive
201/// inverse of `expect_err`. Requires an active scheduler and is
202/// mutually exclusive with both `expect_err` and `expect_auto_repro`
203/// (rejected at macro-parse and by `KtstrTestEntry::validate`).
204/// Enforced on scenarios driven through `execute_defs` /
205/// `execute_steps` / `execute_scenario` (which run the scheduler
206/// liveness probe); a survival violation surfaces as a failing exit
207/// with a survival-specific explainer.
208/// - `extra_include_files = ["PATH", "PATH", ...]` — host-side
209/// file paths to bundle into the guest initramfs beyond what
210/// the entry's `scheduler` / `payload` / `workloads` already
211/// declare via their own `include_files`. Use this for
212/// test-level dependencies that don't belong on a specific
213/// Payload: auxiliary data files, per-test helper scripts,
214/// fixtures. Each element must be a string literal (no
215/// expressions). Maps onto
216/// `KtstrTestEntry::extra_include_files` and is unioned with
217/// the per-payload specs at `run_ktstr_test` time via
218/// `KtstrTestEntry::all_include_files`. Default: `[]`.
219/// Path resolution: bare names (no `/`) search `PATH`; paths
220/// containing `/` are absolute or relative to the test process
221/// current directory; directories are walked recursively at
222/// test-run time (rejected by `cargo ktstr export` since the
223/// `.run` packager handles regular files only — recursive
224/// directory packaging is a v2 enhancement); a missing file
225/// fails loudly at setup with an actionable error naming the
226/// missing path.
227///
228/// Duplicate keys: each attribute KEY may appear at most once per
229/// `#[ktstr_test]` invocation; duplicate keys (whether the values
230/// match or differ) fail at expansion rather than silently letting
231/// the later value win. `#[ktstr_test(host_only = false,
232/// host_only)]` and `#[ktstr_test(llcs = 4, llcs = 8)]` both fail.
233/// The bare form (`host_only`) and explicit form (`host_only =
234/// true`) of the same attribute collide as well — they refer to
235/// the same slot. List values like `workloads = [FIO, FIO]` are
236/// NOT affected by this rule; the duplicate check is on attribute
237/// keys, not on values within an array. `payload = ...` and
238/// `workloads = [..]` keep their tailored messages directing the
239/// author to the right home for extras; `config = ...` and
240/// `expect_scx_bpf_error_{contains,matches} = ...` likewise have
241/// tailored wording; every other attribute uses a uniform
242/// "duplicate attribute" diagnostic.
243///
244/// Path / list forms: `#[ktstr_test(crate::host_only)]` (a
245/// multi-segment path, whether bare or as a key in
246/// `crate::host_only = true`) is rejected with a targeted message
247/// naming both valid forms with concrete examples — the macro only
248/// accepts bare single-segment idents because routing dispatches on
249/// the ident string against `BOOL_ATTR_NAMES` or the value-attr match
250/// arms (enumerated in `VALUE_ATTR_NAMES`).
251/// `#[ktstr_test(host_only(false))]` (parenthesised
252/// arguments) is rejected with a separate targeted message naming
253/// the attribute and the two valid forms (`= value` or bare); the
254/// same diagnostic fires for `crate::host_only(false)` so the
255/// operator sees one combined error rather than chasing two.
256#[proc_macro_attribute]
257pub fn ktstr_test(attr: TokenStream, item: TokenStream) -> TokenStream {
258 match ktstr_test::ktstr_test_impl(attr.into(), item.into()) {
259 Ok(ts) => ts.into(),
260 Err(e) => e.to_compile_error().into(),
261 }
262}
263
264/// Function-style macro that registers a `Scheduler` const.
265///
266/// # Syntax
267///
268/// ```rust,ignore
269/// use ktstr::prelude::*;
270///
271/// declare_scheduler!(MITOSIS, {
272/// name = "mitosis",
273/// binary = "scx_mitosis",
274/// cgroup_parent = "/ktstr",
275/// sched_args = ["--exit-dump-len", "1048576"],
276/// kernels = ["6.14", "7.0..=7.2"],
277/// constraints = TopologyConstraints {
278/// min_llcs: 1, max_llcs: Some(8), max_cpus: Some(64),
279/// ..TopologyConstraints::DEFAULT
280/// },
281/// });
282/// ```
283///
284/// # Generated items
285///
286/// Given `declare_scheduler!(MITOSIS, { ... })`:
287///
288/// - `pub static MITOSIS: ::ktstr::test_support::Scheduler` — the declared
289/// scheduler value. No `_PAYLOAD` suffix; the const IS the
290/// `Scheduler`.
291/// - A hidden `static __KTSTR_SCHED_REG_MITOSIS: &'static Scheduler`
292/// registered in `KTSTR_SCHEDULERS` (`ktstr::test_support::KTSTR_SCHEDULERS`)
293/// via linkme so the verifier can discover the declaration by
294/// spawning the test binary with `--ktstr-list-schedulers`.
295///
296/// # Visibility prefix
297///
298/// An optional Rust visibility prefix may precede the const name:
299///
300/// ```rust,ignore
301/// declare_scheduler!(MY_SCHED, { ... }); // defaults to `pub`
302/// declare_scheduler!(pub MY_SCHED, { ... }); // explicit `pub`
303/// declare_scheduler!(pub(crate) MY_SCHED, { ... }); // crate-local
304/// declare_scheduler!(pub(super) MY_SCHED, { ... }); // parent-module
305/// declare_scheduler!(pub(in crate::test_support) MY_SCHED, { ... });
306/// ```
307///
308/// Omitting the prefix defaults to `pub` — schedulers are normally
309/// public so the verifier and other crates can reference them; an
310/// explicit prefix is needed only when the declaration sits inside
311/// a module that wants to narrow the exposed name. (Field content
312/// shown above as `{ ... }` is elided; consult the Syntax example
313/// for the required fields.) The hidden registry static (see
314/// Generated items above) is always `static` (private) regardless
315/// of the user-facing const's visibility — `linkme` gathers it via
316/// link-section walking, not Rust name resolution, so the slice
317/// mechanism works at every visibility level.
318///
319/// # Accepted fields
320///
321/// Exactly one scheduler-source must be declared: `binary`,
322/// `binary_path`, or the `kernel_builtin_enable` + `kernel_builtin_disable`
323/// pair. The three options select between the matching
324/// `SchedulerSpec` variants. To run under the kernel default
325/// instead, reference `ktstr::test_support::Scheduler::EEVDF`
326/// directly rather than declaring a new scheduler.
327///
328/// | Field | Required | Description |
329/// |---|---|---|
330/// | `name = "..."` | yes | Scheduler name (sidecar / logs). |
331/// | `binary = "..."` | one source | Binary name → `SchedulerSpec::Discover(...)`. Matched against `[[bin]]` names in `target/{debug,release}/`, the test binary's directory, or `KTSTR_SCHEDULER` env var. Often equal to the cargo package name but not required to be. |
332/// | `binary_path = "/abs/path"` | one source | Absolute filesystem path → `SchedulerSpec::Path(...)`. The runtime does not auto-build this variant: the file must already exist at the path when the test runs. Use for prebuilt binaries that live outside the cargo discovery cascade. Macro-time validation rejects empty strings, relative paths, and `~`-prefixed paths (no compile-time tilde expansion); existence is the runtime's job. |
333/// | `kernel_builtin_enable = [..]` + `kernel_builtin_disable = [..]` | one source | Two string-array literals that together select `SchedulerSpec::KernelBuiltin { enable: &[..], disable: &[..] }`. The framework writes the enable commands to the guest's `/sched_enable` and the disable commands to `/sched_disable` (see `src/vmm/initramfs.rs`), and the guest interpreter runs each entry once at scenario start / teardown. Both fields must be set together — setting only one is rejected. The interpreter (`src/vmm/rust_init/dump.rs`) accepts EXACTLY ONE shell-line shape: `echo VALUE > /path` (plus blank lines and `#` comments). Pipes, `>>`, `;`, variable expansion, and any other syntax silently no-ops at runtime, so the macro rejects entries that don't match `echo … > /…` at expand time. At least one of the two arrays must be non-empty: a pair that supplies neither enable nor disable commands is equivalent to the EEVDF baseline — reference `Scheduler::EEVDF` for that. Note: `cargo ktstr export` currently bails on KernelBuiltin schedulers (`src/export.rs`); declarations using this variant cannot be reproduced via the export-to-shar workflow until that limitation is lifted. |
334/// | `topology = (numa, llcs, cores, threads)` | no | Default VM topology. Default: `(1, 1, 2, 1)` (from `Scheduler::named`). Validated at compile time: each value must be non-zero, and `llcs` must be a multiple of `numa`. |
335/// | `cgroup_parent = "..."` | no | Cgroup parent path (must begin with `/`). |
336/// | `sched_args = [..]` | no | Scheduler CLI args prepended before per-test `extra_sched_args`. |
337/// | `sysctls = [Sysctl::new("k", "v"), ..]` | no | Guest sysctls. |
338/// | `kargs = [..]` | no | Extra guest kernel cmdline args. |
339/// | `kernels = ["6.14", "7.0..=7.2", ..]` | no | Kernel specs the verifier sweeps. Same parser as the `--kernel` CLI flag — accepts exact versions, ranges (`..` or `..=`, both inclusive), git refs (`git+URL#tag=NAME`), paths, and cache keys. Each entry is validated at macro-expand time via the same `KernelId::parse` + `validate` the verifier uses at runtime; empty entries, inverted ranges, and `..`-containing strings whose endpoints aren't version-shaped (e.g. `"abc..def"`) are rejected. |
340/// | `constraints = TopologyConstraints { .. }` | no | Gauntlet preset constraints — maps directly onto `Scheduler::constraints`. Filters which gauntlet topology presets exercise this scheduler. When given as a struct literal, the macro additionally cross-checks each literal field against the effective topology (explicit `topology` field if present, otherwise the `(1, 1, 2, 1)` default from `Scheduler::named`) and rejects infeasible pairings; non-struct-literal forms (e.g. `OTHER::CONST_CONSTRAINTS`) skip that check. |
341/// | `assert = Assert::NO_OVERRIDES.method().chain()` | no | Scheduler-wide assertion overrides — maps directly onto `Scheduler::assert`. Merged with `Assert::default_checks()` and the per-test `assert` at runtime (`default ← scheduler ← per-test`). Accepts any const-evaluable expression: a const path like `Assert::NO_OVERRIDES`, a const-fn call like `Assert::default_checks()`, or a chain of const-fn setters like `Assert::NO_OVERRIDES.check_not_starved().max_gap_ms(50)`. The macro accepts MethodCall chains and Path-rooted (type/module-prefixed) Calls — only bare single-segment lowercase Calls like `helper()` are rejected as non-const free-fn patterns; non-const methods on a Path receiver slip through and surface as a deep const-eval failure at the spread site. |
342/// | `config_file = "..."` | no | Host-side config file path. |
343/// | `config_file_def = ("--config {file}", "/include-files/cfg.json")` | no | Inline-config plumbing — maps directly onto `Scheduler::config_file_def`. 2-tuple of string literals: arg_template (CLI arg with `{file}` placeholder substituted at run time) and guest_path (absolute path where the framework writes the JSON inside the guest). Distinct from `config_file` (which references a pre-existing host file). The macro validates: tuple-arity = 2, both elements non-empty string literals, `{file}` placeholder present in arg_template, guest_path absolute. |
344///
345/// # Const naming rules
346///
347/// The first argument must be a SCREAMING_SNAKE_CASE identifier and
348/// must NOT be one of the reserved built-in names (`EEVDF`,
349/// `KERNEL_DEFAULT`). The macro emits a `compile_error!` if either rule
350/// is violated.
351#[proc_macro]
352pub fn declare_scheduler(input: TokenStream) -> TokenStream {
353 match scheduler::declare_scheduler_inner(input.into()) {
354 Ok(ts) => ts.into(),
355 Err(e) => e.to_compile_error().into(),
356 }
357}
358
359/// Derive macro that generates a `Payload` const from an annotated
360/// struct for a userspace binary workload (stress-ng, fio, and
361/// similar tools test authors compose under a scheduler).
362///
363/// # Required struct-level attributes (`#[payload(...)]`)
364///
365/// - `binary = "..."` — the binary name resolved by the guest's
366/// include-files infrastructure (required). Becomes
367/// `PayloadKind::Binary(name)` (`ktstr::test_support::PayloadKind::Binary`),
368/// and is also auto-prepended to the emitted `include_files` slice
369/// so the binary is packaged into the initramfs without needing a
370/// separate `#[include_files("...")]` entry. Extra auxiliary files
371/// (helpers, configs, fixtures) still go on `#[include_files(...)]`.
372///
373/// # Optional struct-level attributes
374///
375/// - `name = "..."` — short name used in logs and sidecar records.
376/// Defaults to the binary name.
377/// - `output = Json | ExitCode` — how the framework extracts
378/// metrics from the payload's stdout. The variant names match the
379/// `OutputFormat` enum and the `Polarity` kwarg grammar. Defaults
380/// to `ExitCode`.
381///
382/// # Optional outer attributes
383///
384/// - `#[default_args("--a", "--b", ...)]` — variadic string
385/// literals appended to the binary's argv when the payload runs.
386/// May repeat across multiple `#[default_args(...)]` attrs; entries
387/// accumulate in source order.
388/// - `#[default_check(...)]` — one `MetricCheck` (`ktstr::test_support::MetricCheck`)
389/// construction expression (e.g. `min("iops", 1000.0)`,
390/// `exit_code_eq(0)`). May repeat; entries accumulate in source
391/// order. Both `min(...)` and `MetricCheck::min(...)` are accepted: the
392/// macro prepends `::ktstr::test_support::MetricCheck::` when the
393/// expression doesn't already spell `MetricCheck::` on its callee path,
394/// so bare constructors work without an import and qualified
395/// constructors read naturally in modules that already have
396/// `MetricCheck` in scope.
397/// - `#[metric(name = "...", polarity = ..., unit = "...")]` —
398/// kwarg form. `polarity` is one of `HigherBetter`, `LowerBetter`,
399/// `TargetValue(f64)`, `Unknown`. May repeat; entries accumulate.
400/// - `#[include_files("helper", "config.json", ...)]` — variadic
401/// string literals appended to the emitted `include_files` slice
402/// after the auto-injected binary entry. Each entry passes through
403/// the same resolver used by the CLI `-i` flag (bare names search
404/// host `PATH`; explicit paths must exist; directories are walked).
405/// The primary binary is already packaged automatically, so this
406/// attribute is only needed for auxiliary files the payload
407/// depends on.
408///
409/// # Const name derivation
410///
411/// Strip trailing `"Payload"` suffix (if present), then convert to
412/// `SCREAMING_SNAKE_CASE`. `FioPayload` → `FIO`,
413/// `StressNgPayload` → `STRESS_NG`, `Fio` (no suffix) → `FIO`.
414///
415/// # Example
416///
417/// ```rust,ignore
418/// use ktstr::prelude::*;
419///
420/// #[derive(Payload)]
421/// #[payload(binary = "fio", output = Json)]
422/// #[default_args("--output-format=json", "--minimal")]
423/// #[default_check(exit_code_eq(0))]
424/// #[metric(name = "jobs.0.read.iops", polarity = HigherBetter, unit = "iops")]
425/// struct FioPayload;
426/// ```
427#[proc_macro_derive(
428 Payload,
429 attributes(payload, default_args, default_check, metric, include_files)
430)]
431pub fn derive_payload(input: TokenStream) -> TokenStream {
432 let input = parse_macro_input!(input as DeriveInput);
433 match payload::derive_payload_inner(input) {
434 Ok(ts) => ts.into(),
435 Err(e) => e.to_compile_error().into(),
436 }
437}
438
439/// Generate per-field claim accessors on a stats struct.
440///
441/// See the `claim` module docs for the dispatch rules and label
442/// invariant. Reject non-struct inputs and tuple-struct inputs — the
443/// claim API is keyed on field names, which tuple structs do not have.
444#[proc_macro_derive(Claim, attributes(claim))]
445pub fn derive_claim(input: TokenStream) -> TokenStream {
446 let input = parse_macro_input!(input as DeriveInput);
447 match claim::derive_claim_inner(input) {
448 Ok(ts) => ts.into(),
449 Err(e) => e.to_compile_error().into(),
450 }
451}
452
453/// Convert JSON-like Rust tokens into a `&'static str` at compile time.
454///
455/// Accepts a superset of JSON syntax using Rust token trees:
456/// - Objects: `{ "key": value, ... }`
457/// - Arrays: `[value, ...]`
458/// - Strings: `"hello"`
459/// - Numbers: `42`, `3.14`, `-1`
460/// - Booleans: `true`, `false`
461/// - Null: `null`
462/// - Trailing commas are stripped
463///
464/// ```rust,ignore
465/// const CFG: &str = ktstr::json!({
466/// "layers": [{
467/// "name": "batch",
468/// "kind": { "Grouped": { "cpus_range": [0, 4] } },
469/// }],
470/// });
471/// ```
472#[proc_macro]
473pub fn json(input: TokenStream) -> TokenStream {
474 let mut out = String::new();
475 json::tokens_to_json(&mut out, proc_macro2::TokenStream::from(input));
476 let lit = syn::LitStr::new(&out, proc_macro2::Span::call_site());
477 TokenStream::from(quote! { #lit })
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use quote::quote;
484
485 #[test]
486 fn camel_to_screaming_snake_acronym_run() {
487 assert_eq!(
488 payload::camel_to_screaming_snake("HTTPServer"),
489 "HTTP_SERVER"
490 );
491 }
492
493 #[test]
494 fn camel_to_screaming_snake_single_word() {
495 assert_eq!(payload::camel_to_screaming_snake("Llc"), "LLC");
496 }
497
498 #[test]
499 fn camel_to_screaming_snake_all_caps_passthrough() {
500 assert_eq!(payload::camel_to_screaming_snake("LLC"), "LLC");
501 }
502
503 #[test]
504 fn option_tokens_some_int() {
505 let opt: Option<u32> = Some(42);
506 let ts = ktstr_test::option_tokens(&opt);
507 assert_eq!(ts.to_string(), quote! { Some(42u32) }.to_string());
508 }
509
510 #[test]
511 fn option_tokens_none_int() {
512 let opt: Option<u32> = None;
513 let ts = ktstr_test::option_tokens(&opt);
514 assert_eq!(ts.to_string(), quote! { None }.to_string());
515 }
516
517 #[test]
518 fn option_tokens_some_bool() {
519 let opt: Option<bool> = Some(true);
520 let ts = ktstr_test::option_tokens(&opt);
521 assert_eq!(ts.to_string(), quote! { Some(true) }.to_string());
522 }
523
524 /// Pins the two attribute-name registries the unknown-attribute diagnostic
525 /// is derived from: they must be internally duplicate-free, disjoint (a
526 /// name is a bool flag XOR a value attr, never both), and sum to the full
527 /// accepted set. A maintainer adding an attribute to the wrong slice — or
528 /// to both — silently shifts the user-facing "expected:" list; this catches
529 /// it. (The complementary direction — a name in a slice with no handling
530 /// match arm — is guarded at parse time by the unknown-attribute catch-all
531 /// `assert!`.)
532 #[test]
533 fn attr_name_registries_disjoint_and_complete() {
534 use std::collections::HashSet;
535 let bool_names = ktstr_test::BOOL_ATTR_NAMES;
536 let value_names = ktstr_test::VALUE_ATTR_NAMES;
537 let bool_set: HashSet<&str> = bool_names.iter().copied().collect();
538 let value_set: HashSet<&str> = value_names.iter().copied().collect();
539 // No duplicates within either slice.
540 assert_eq!(
541 bool_set.len(),
542 bool_names.len(),
543 "BOOL_ATTR_NAMES has duplicate entries",
544 );
545 assert_eq!(
546 value_set.len(),
547 value_names.len(),
548 "VALUE_ATTR_NAMES has duplicate entries",
549 );
550 // Disjoint: an attribute is a bool flag XOR a value attr.
551 let overlap: Vec<&str> = bool_set.intersection(&value_set).copied().collect();
552 assert!(
553 overlap.is_empty(),
554 "BOOL_ATTR_NAMES and VALUE_ATTR_NAMES overlap: {overlap:?}",
555 );
556 // Cardinality pin: 16 bool + 50 value = 66 accepted attributes.
557 assert_eq!(bool_names.len(), 16, "bool attribute count changed");
558 assert_eq!(value_names.len(), 50, "value attribute count changed");
559 }
560
561 /// Contract pin: `ktstr_test::AttrValues::default()` is the single source of
562 /// truth for every `#[ktstr_test]` macro default since step 2/4 of
563 /// the parse-loop refactor. Without a field-by-field positive
564 /// assertion a maintainer editing the [`Default`] impl can shift
565 /// any user-visible default (auto_repro, kaslr, memory_mib, the
566 /// gauntlet caps, etc.) with zero test feedback. Same precedent
567 /// as `resolve_host_cgroup_parent_env_unset_returns_default` in
568 /// src/test_support/dispatch_tests.rs pinning a runtime const
569 /// against production source.
570 #[test]
571 fn attr_values_default_matches_documented_macro_defaults() {
572 let d = ktstr_test::AttrValues::default();
573
574 // -- Topology --
575 assert_eq!(d.llcs, ktstr_test::DEFAULT_LLCS);
576 assert_eq!(d.cores, ktstr_test::DEFAULT_CORES);
577 assert_eq!(d.threads, ktstr_test::DEFAULT_THREADS);
578 assert_eq!(d.numa_nodes, 1);
579 assert!(!d.llcs_set);
580 assert!(!d.cores_set);
581 assert!(!d.threads_set);
582 assert!(!d.numa_nodes_set);
583
584 // -- Memory + duration --
585 assert_eq!(d.memory_mib, ktstr_test::DEFAULT_MEMORY_MIB);
586 assert!(!d.memory_mib_set);
587 assert_eq!(d.duration_s, 2);
588 assert!(!d.duration_s_set);
589 assert_eq!(d.cleanup_budget_ms, None);
590 assert_eq!(d.watchdog_timeout_s, 4);
591 assert!(!d.watchdog_timeout_s_set);
592
593 // -- Scheduler refs --
594 assert!(d.scheduler.is_none());
595 assert!(d.payload.is_none());
596 assert!(d.workloads.is_none());
597 assert!(d.staged_schedulers.is_none());
598 assert!(d.bpf_map_write.is_none());
599 assert!(d.post_vm.is_none());
600 assert!(d.post_vm_unconditional.is_none());
601 assert!(d.disk.is_none());
602 assert!(d.networks.is_none());
603
604 // -- Assert overrides --
605 assert_eq!(d.not_starved, None);
606 assert_eq!(d.isolation, None);
607 assert_eq!(d.max_gap_ms, None);
608 assert_eq!(d.max_spread_pct, None);
609 assert_eq!(d.max_imbalance_ratio, None);
610 assert_eq!(d.max_local_dsq_depth, None);
611 assert_eq!(d.fail_on_stall, None);
612 assert_eq!(d.sustained_samples, None);
613 assert_eq!(d.max_throughput_cv, None);
614 assert_eq!(d.min_work_rate, None);
615 assert_eq!(d.max_fallback_rate, None);
616 assert_eq!(d.max_keep_last_rate, None);
617 assert_eq!(d.max_p99_wake_latency_ns, None);
618 assert_eq!(d.max_wake_latency_cv, None);
619 assert_eq!(d.min_iteration_rate, None);
620 assert_eq!(d.max_migration_ratio, None);
621 assert_eq!(d.min_page_locality, None);
622 assert_eq!(d.max_cross_node_migration_ratio, None);
623 assert_eq!(d.max_slow_tier_ratio, None);
624
625 // -- TopologyConstraints --
626 assert_eq!(d.min_numa_nodes, 1);
627 assert!(!d.min_numa_nodes_set);
628 assert_eq!(d.min_llcs, 1);
629 assert!(!d.min_llcs_set);
630 assert!(!d.requires_smt);
631 assert!(!d.requires_smt_set);
632 assert_eq!(d.min_cpus, 1);
633 assert!(!d.min_cpus_set);
634 assert_eq!(d.max_llcs, Some(12));
635 assert!(!d.max_llcs_set);
636 assert_eq!(d.max_numa_nodes, Some(1));
637 assert!(!d.max_numa_nodes_set);
638 assert_eq!(d.max_cpus, Some(192));
639 assert!(!d.max_cpus_set);
640 assert_eq!(d.cpu_budget, None);
641
642 // -- Bool attrs (auto_repro + kaslr default TRUE; others false) --
643 assert!(d.auto_repro);
644 assert!(!d.auto_repro_set);
645 assert!(!d.expect_auto_repro);
646 assert!(!d.expect_auto_repro_set);
647 assert!(!d.performance_mode);
648 assert!(!d.performance_mode_set);
649 assert!(!d.no_perf_mode);
650 assert!(!d.no_perf_mode_set);
651 assert!(!d.expect_err);
652 assert!(!d.expect_err_set);
653 assert!(!d.allow_inconclusive);
654 assert!(!d.allow_inconclusive_set);
655 assert!(!d.host_only);
656 assert!(!d.host_only_set);
657 assert!(!d.ignore_test);
658 assert!(d.kaslr);
659 assert!(!d.kaslr_set);
660 assert!(!d.wprof);
661 assert!(!d.wprof_set);
662 assert_eq!(d.num_snapshots, 0);
663 assert!(!d.num_snapshots_set);
664
665 // -- Strings + tokens --
666 assert!(d.extra_sched_args.is_empty());
667 assert!(d.extra_include_files.is_empty());
668 assert_eq!(d.workload_root_cgroup, None);
669 assert!(d.wprof_args.is_none());
670 assert!(d.expect_scx_bpf_error_contains_tokens.is_none());
671 assert!(d.expect_scx_bpf_error_matches_tokens.is_none());
672 assert!(d.config_expr.is_none());
673 assert!(!d.config_set);
674 }
675
676 // -- expect_auto_repro macro-parse positive tests --
677 //
678 // Synthesize each attribute spelling, invoke ktstr_test_impl
679 // directly (bypassing proc_macro::TokenStream which panics outside
680 // a procedural-macro invocation), parse the output back into a
681 // syn AST, locate the `static __KTSTR_ENTRY_*: KtstrTestEntry =
682 // KtstrTestEntry { ... };` registration emitted by the macro, and
683 // assert the `expect_auto_repro` field is either:
684 // - absent (omitted spelling — DEFAULT spread carries false), or
685 // - present with a `Lit::Bool { value: true/false }` value.
686 //
687 // The AST round-trip (rather than substring matching on the
688 // output's `.to_string()`) guards against two failure modes:
689 // 1. proc_macro2 version drift in colon/whitespace formatting —
690 // a future proc_macro2 release that emits `field: true` (no
691 // space) vs `field : true` (current) would silently flip a
692 // substring-matching test from PASS to FAIL or vice versa.
693 // 2. structural defects that a substring check cannot detect —
694 // wrong outer struct name, wrong field value type (e.g. a
695 // String literal where a bool is expected), or a phantom
696 // sub-literal elsewhere in the output that happens to
697 // contain the same substring.
698 //
699 // For spellings that set expect_auto_repro = true, the cross-
700 // attribute validation pass (added at the same time as the field)
701 // requires a scheduler attribute + wprof attribute to be present.
702 // The fixture inputs satisfy those preconditions so the parser
703 // reaches codegen without rejection.
704
705 /// Type-erased extraction from a `syn::Expr` for the
706 /// [`field_value_in_static_entry`] helper. Each impl panics with
707 /// a descriptive error if the expression shape doesn't match the
708 /// expected literal kind — same wrong-type-rejection contract as
709 /// the single-purpose helper this generalization replaces.
710 trait ExtractFromExpr: Sized {
711 fn extract_or_panic(field_name: &str, expr: &syn::Expr) -> Self;
712 }
713
714 impl ExtractFromExpr for bool {
715 fn extract_or_panic(field_name: &str, expr: &syn::Expr) -> Self {
716 match expr {
717 syn::Expr::Lit(syn::ExprLit {
718 lit: syn::Lit::Bool(b),
719 ..
720 }) => b.value,
721 other => panic!("{field_name} field value must be a Lit::Bool; got {other:?}"),
722 }
723 }
724 }
725
726 impl ExtractFromExpr for u32 {
727 fn extract_or_panic(field_name: &str, expr: &syn::Expr) -> Self {
728 match expr {
729 syn::Expr::Lit(syn::ExprLit {
730 lit: syn::Lit::Int(i),
731 ..
732 }) => i
733 .base10_parse::<u32>()
734 .unwrap_or_else(|e| panic!("{field_name} field value parse as u32: {e}")),
735 other => panic!("{field_name} field value must be a Lit::Int (u32); got {other:?}"),
736 }
737 }
738 }
739
740 impl ExtractFromExpr for u64 {
741 fn extract_or_panic(field_name: &str, expr: &syn::Expr) -> Self {
742 match expr {
743 syn::Expr::Lit(syn::ExprLit {
744 lit: syn::Lit::Int(i),
745 ..
746 }) => i
747 .base10_parse::<u64>()
748 .unwrap_or_else(|e| panic!("{field_name} field value parse as u64: {e}")),
749 other => panic!("{field_name} field value must be a Lit::Int (u64); got {other:?}"),
750 }
751 }
752 }
753
754 impl ExtractFromExpr for String {
755 fn extract_or_panic(field_name: &str, expr: &syn::Expr) -> Self {
756 match expr {
757 syn::Expr::Lit(syn::ExprLit {
758 lit: syn::Lit::Str(s),
759 ..
760 }) => s.value(),
761 other => panic!("{field_name} field value must be a Lit::Str; got {other:?}"),
762 }
763 }
764 }
765
766 impl ExtractFromExpr for syn::Path {
767 fn extract_or_panic(field_name: &str, expr: &syn::Expr) -> Self {
768 match expr {
769 syn::Expr::Path(p) => p.path.clone(),
770 other => panic!("{field_name} field value must be an Expr::Path; got {other:?}"),
771 }
772 }
773 }
774
775 /// Locate the `__KTSTR_ENTRY_*` static the macro emits and return
776 /// the value of its `field_name` field if explicitly set, or
777 /// `None` if the field is absent from the struct literal
778 /// (omitted spellings fall through to the
779 /// `..KtstrTestEntry::DEFAULT` spread).
780 ///
781 /// Generalized from the single-field `expect_auto_repro` helper
782 /// to read any field whose value-type implements
783 /// [`ExtractFromExpr`] (bool, u32, u64, String, syn::Path).
784 /// The wrong-value-type rejection moves into the per-type
785 /// `extract_or_panic` impl — a field whose expression doesn't
786 /// match the requested type panics with a message naming both
787 /// the field and the expected type.
788 ///
789 /// Verifies along the way:
790 /// - exactly one `__KTSTR_ENTRY_*` static is emitted (panics on
791 /// zero or multiple — a future codegen change that emitted
792 /// two prefixed statics would silently key off the first
793 /// without the count assertion),
794 /// - its declared type's last path segment is `KtstrTestEntry`,
795 /// - its initializer is a struct literal whose path's last
796 /// segment is `KtstrTestEntry` (catches a regression that
797 /// wrapped the literal in a different outer struct),
798 /// - any present `field_name` field's value matches the
799 /// requested type (panics via [`ExtractFromExpr::extract_or_panic`]
800 /// otherwise).
801 fn field_value_in_static_entry<T: ExtractFromExpr>(
802 output: &proc_macro2::TokenStream,
803 field_name: &str,
804 ) -> Option<T> {
805 let file: syn::File =
806 syn::parse2(output.clone()).expect("macro output must parse as a syn::File");
807 let static_candidates: Vec<&syn::ItemStatic> = file
808 .items
809 .iter()
810 .filter_map(|item| match item {
811 syn::Item::Static(s) if s.ident.to_string().starts_with("__KTSTR_ENTRY_") => {
812 Some(s)
813 }
814 _ => None,
815 })
816 .collect();
817 assert_eq!(
818 static_candidates.len(),
819 1,
820 "macro must emit exactly one __KTSTR_ENTRY_* static; found {}",
821 static_candidates.len()
822 );
823 let static_item = static_candidates[0];
824 let static_type_last = match static_item.ty.as_ref() {
825 syn::Type::Path(tp) => tp.path.segments.last().map(|s| s.ident.to_string()),
826 _ => None,
827 };
828 assert_eq!(
829 static_type_last.as_deref(),
830 Some("KtstrTestEntry"),
831 "static type's last path segment must be KtstrTestEntry"
832 );
833 let expr_struct = match static_item.expr.as_ref() {
834 syn::Expr::Struct(s) => s,
835 other => panic!("static initializer must be a struct literal; got {other:?}"),
836 };
837 let struct_last = expr_struct
838 .path
839 .segments
840 .last()
841 .map(|s| s.ident.to_string());
842 assert_eq!(
843 struct_last.as_deref(),
844 Some("KtstrTestEntry"),
845 "struct-literal path's last segment must be KtstrTestEntry"
846 );
847 for field in &expr_struct.fields {
848 let ident_matches = matches!(
849 &field.member,
850 syn::Member::Named(ident) if ident == field_name
851 );
852 if !ident_matches {
853 continue;
854 }
855 return Some(T::extract_or_panic(field_name, &field.expr));
856 }
857 None
858 }
859
860 /// `#[ktstr_test(expect_auto_repro)]` (bare form) emits
861 /// `expect_auto_repro: true` as a field on the
862 /// `KtstrTestEntry` struct literal. Pins the bare-flag arm of
863 /// the macro's bool-slot parser. Gated on the `wprof` feature: the
864 /// attr pairs `wprof` with `expect_auto_repro` (the latter requires the
865 /// former), and the macro only accepts `wprof` when the feature is on —
866 /// so this case can only parse successfully under `--features wprof`.
867 #[cfg(feature = "wprof")]
868 #[test]
869 fn macro_parses_expect_auto_repro_bare_to_true() {
870 let attr = quote! { scheduler = SCHED, wprof, expect_auto_repro };
871 let item = quote! {
872 fn t(_: &ktstr::scenario::Ctx) -> anyhow::Result<ktstr::assert::AssertResult> {
873 Ok(ktstr::assert::AssertResult::pass())
874 }
875 };
876 let out = ktstr_test::ktstr_test_impl(attr, item)
877 .expect("bare attribute must parse successfully");
878 assert_eq!(
879 field_value_in_static_entry::<bool>(&out, "expect_auto_repro"),
880 Some(true),
881 "bare `expect_auto_repro` must emit a `expect_auto_repro: true` field"
882 );
883 }
884
885 /// `#[ktstr_test(expect_auto_repro = true)]` emits
886 /// `expect_auto_repro: true`. Pins the explicit-true arm. Gated on the
887 /// `wprof` feature (the attr requires `wprof`, accepted only with the
888 /// feature on).
889 #[cfg(feature = "wprof")]
890 #[test]
891 fn macro_parses_expect_auto_repro_explicit_true() {
892 let attr = quote! { scheduler = SCHED, wprof, expect_auto_repro = true };
893 let item = quote! {
894 fn t(_: &ktstr::scenario::Ctx) -> anyhow::Result<ktstr::assert::AssertResult> {
895 Ok(ktstr::assert::AssertResult::pass())
896 }
897 };
898 let out = ktstr_test::ktstr_test_impl(attr, item)
899 .expect("explicit-true attribute must parse successfully");
900 assert_eq!(
901 field_value_in_static_entry::<bool>(&out, "expect_auto_repro"),
902 Some(true),
903 "explicit `expect_auto_repro = true` must emit a `expect_auto_repro: true` field"
904 );
905 }
906
907 /// `#[ktstr_test(expect_auto_repro = false)]` emits
908 /// `expect_auto_repro: false`. Pins the explicit-false arm
909 /// against a regression that conflated explicit-false with
910 /// omission (which would silently leave DEFAULT untouched and
911 /// lose the user's negative declaration). No cross-attribute
912 /// gates apply when expect_auto_repro is false — the only
913 /// rejection arms trigger on the true value.
914 #[test]
915 fn macro_parses_expect_auto_repro_explicit_false() {
916 let attr = quote! { expect_auto_repro = false };
917 let item = quote! {
918 fn t(_: &ktstr::scenario::Ctx) -> anyhow::Result<ktstr::assert::AssertResult> {
919 Ok(ktstr::assert::AssertResult::pass())
920 }
921 };
922 let out = ktstr_test::ktstr_test_impl(attr, item)
923 .expect("explicit-false attribute must parse successfully");
924 assert_eq!(
925 field_value_in_static_entry::<bool>(&out, "expect_auto_repro"),
926 Some(false),
927 "explicit `expect_auto_repro = false` must emit a `expect_auto_repro: false` field"
928 );
929 }
930
931 /// Omitting the attribute entirely emits NO
932 /// `expect_auto_repro` field — the generated struct literal
933 /// uses the `..KtstrTestEntry::DEFAULT` spread to inherit the
934 /// false default. Pins backward-compat: an existing
935 /// `#[ktstr_test(...)]` with no expect_auto_repro must not
936 /// gain a phantom field that flips the entry's behavior.
937 #[test]
938 fn macro_parses_omitted_expect_auto_repro_leaves_field_unemitted() {
939 let attr = quote! {};
940 let item = quote! {
941 fn t(_: &ktstr::scenario::Ctx) -> anyhow::Result<ktstr::assert::AssertResult> {
942 Ok(ktstr::assert::AssertResult::pass())
943 }
944 };
945 let out = ktstr_test::ktstr_test_impl(attr, item)
946 .expect("attribute-less invocation must parse successfully");
947 assert_eq!(
948 field_value_in_static_entry::<bool>(&out, "expect_auto_repro"),
949 None,
950 "omitted attribute must NOT emit any `expect_auto_repro` field — DEFAULT spread carries the false"
951 );
952 }
953
954 /// `#[ktstr_test(scheduler = SCHED, survives_storm)]` emits
955 /// `survives_storm: true`. Pins BOOL_ATTR_NAMES + assign_bool + codegen.
956 /// A scheduler token is present so the mutex does not reject.
957 #[test]
958 fn macro_parses_survives_storm_bare_to_true() {
959 let attr = quote! { scheduler = SCHED, survives_storm };
960 let item = quote! {
961 fn t(_: &ktstr::scenario::Ctx) -> anyhow::Result<ktstr::assert::AssertResult> {
962 Ok(ktstr::assert::AssertResult::pass())
963 }
964 };
965 let out = ktstr_test::ktstr_test_impl(attr, item)
966 .expect("bare survives_storm with a scheduler must parse");
967 assert_eq!(
968 field_value_in_static_entry::<bool>(&out, "survives_storm"),
969 Some(true),
970 "bare `survives_storm` must emit `survives_storm: true`"
971 );
972 }
973
974 /// `#[ktstr_test(survives_storm = false)]` emits `survives_storm: false`
975 /// and needs no scheduler (the mutex only fires on the true value).
976 #[test]
977 fn macro_parses_survives_storm_explicit_false() {
978 let attr = quote! { survives_storm = false };
979 let item = quote! {
980 fn t(_: &ktstr::scenario::Ctx) -> anyhow::Result<ktstr::assert::AssertResult> {
981 Ok(ktstr::assert::AssertResult::pass())
982 }
983 };
984 let out = ktstr_test::ktstr_test_impl(attr, item)
985 .expect("explicit-false survives_storm must parse");
986 assert_eq!(
987 field_value_in_static_entry::<bool>(&out, "survives_storm"),
988 Some(false),
989 "explicit `survives_storm = false` must emit `survives_storm: false`"
990 );
991 }
992
993 /// `survives_storm` + `expect_err` is rejected at macro parse —
994 /// contradictory polarity. Pins the `validate_survives_storm_mutex`
995 /// expect_err arm.
996 #[test]
997 fn macro_rejects_survives_storm_with_expect_err() {
998 let attr = quote! { scheduler = SCHED, survives_storm, expect_err = true };
999 let item = quote! {
1000 fn t(_: &ktstr::scenario::Ctx) -> anyhow::Result<ktstr::assert::AssertResult> {
1001 Ok(ktstr::assert::AssertResult::pass())
1002 }
1003 };
1004 let err = ktstr_test::ktstr_test_impl(attr, item).unwrap_err();
1005 assert!(
1006 err.to_string().contains("survives_storm") && err.to_string().contains("expect_err"),
1007 "diagnostic must name both survives_storm and expect_err: {err}"
1008 );
1009 }
1010
1011 /// `survives_storm` without a scheduler is rejected at macro parse —
1012 /// the kernel default has no scx scheduler to die or be ejected.
1013 #[test]
1014 fn macro_rejects_survives_storm_without_scheduler() {
1015 let attr = quote! { survives_storm };
1016 let item = quote! {
1017 fn t(_: &ktstr::scenario::Ctx) -> anyhow::Result<ktstr::assert::AssertResult> {
1018 Ok(ktstr::assert::AssertResult::pass())
1019 }
1020 };
1021 let err = ktstr_test::ktstr_test_impl(attr, item).unwrap_err();
1022 assert!(
1023 err.to_string().contains("survives_storm") && err.to_string().contains("scheduler"),
1024 "diagnostic must name survives_storm and the missing scheduler: {err}"
1025 );
1026 }
1027
1028 /// `survives_storm` + `expect_auto_repro` is rejected at macro parse —
1029 /// both are inversion intents (survives_storm forces a death fail to
1030 /// EXIT_FAIL; expect_auto_repro inverts a crash-with-repro fail to
1031 /// PASS). Feature-agnostic: `validate_cross_attr` runs the
1032 /// survives_storm mutex (which fires on the `expect_auto_repro` arm)
1033 /// BEFORE the `#[cfg(not(feature = "wprof"))]` wprof-required rejection,
1034 /// so the diagnostic names both regardless of the `wprof` feature —
1035 /// unlike the positive parse tests, which need `--features wprof` to
1036 /// reach codegen successfully. The runtime twin is
1037 /// `validate_rejects_survives_storm_with_expect_auto_repro`.
1038 #[test]
1039 fn macro_rejects_survives_storm_with_expect_auto_repro() {
1040 let attr = quote! { scheduler = SCHED, wprof, survives_storm, expect_auto_repro };
1041 let item = quote! {
1042 fn t(_: &ktstr::scenario::Ctx) -> anyhow::Result<ktstr::assert::AssertResult> {
1043 Ok(ktstr::assert::AssertResult::pass())
1044 }
1045 };
1046 let err = ktstr_test::ktstr_test_impl(attr, item).unwrap_err();
1047 assert!(
1048 err.to_string().contains("survives_storm")
1049 && err.to_string().contains("expect_auto_repro"),
1050 "diagnostic must name both survives_storm and expect_auto_repro: {err}"
1051 );
1052 }
1053
1054 // -- check_visible_lit (commit f4018278) --------------------------
1055
1056 #[test]
1057 fn check_visible_lit_visible_string_passes() {
1058 let expr: syn::Expr = syn::parse_quote!("hello");
1059 scheduler::check_visible_lit("hello", &expr, "name")
1060 .expect("non-empty visible string must pass");
1061 }
1062
1063 #[test]
1064 fn check_visible_lit_empty_string_rejected() {
1065 let expr: syn::Expr = syn::parse_quote!("");
1066 let err = scheduler::check_visible_lit("", &expr, "name").unwrap_err();
1067 assert!(
1068 err.to_string()
1069 .contains("`name` must contain at least one visible character"),
1070 "expected `name` visible-empty diagnostic, got: {err}"
1071 );
1072 }
1073
1074 #[test]
1075 fn check_visible_lit_whitespace_only_rejected() {
1076 let expr: syn::Expr = syn::parse_quote!(" ");
1077 let err = scheduler::check_visible_lit(" ", &expr, "binary").unwrap_err();
1078 assert!(
1079 err.to_string()
1080 .contains("`binary` must contain at least one visible character"),
1081 "expected `binary` visible-empty diagnostic, got: {err}"
1082 );
1083 }
1084
1085 #[test]
1086 fn check_visible_lit_invisible_only_rejected() {
1087 let expr: syn::Expr = syn::parse_quote!("zwsp");
1088 let err = scheduler::check_visible_lit("\u{200B}", &expr, "binary_path").unwrap_err();
1089 assert!(
1090 err.to_string()
1091 .contains("`binary_path` must contain at least one visible character"),
1092 "expected `binary_path` visible-empty diagnostic, got: {err}"
1093 );
1094 }
1095
1096 // -- validate_kernel_builtin_pair (commit 753ecf9e) ---------------
1097
1098 #[test]
1099 fn validate_kernel_builtin_pair_both_set_passes() {
1100 let span = proc_macro2::Span::call_site();
1101 scheduler::validate_kernel_builtin_pair(Some(span), Some(span))
1102 .expect("both set is valid (KernelBuiltin)");
1103 }
1104
1105 #[test]
1106 fn validate_kernel_builtin_pair_neither_set_passes() {
1107 scheduler::validate_kernel_builtin_pair(None, None)
1108 .expect("neither set is valid (not KernelBuiltin)");
1109 }
1110
1111 #[test]
1112 fn validate_kernel_builtin_pair_enable_only_rejected() {
1113 let span = proc_macro2::Span::call_site();
1114 let err = scheduler::validate_kernel_builtin_pair(Some(span), None).unwrap_err();
1115 assert!(
1116 err.to_string()
1117 .contains("`kernel_builtin_enable` set without `kernel_builtin_disable`"),
1118 "expected enable-without-disable diagnostic, got: {err}"
1119 );
1120 }
1121
1122 #[test]
1123 fn validate_kernel_builtin_pair_disable_only_rejected() {
1124 let span = proc_macro2::Span::call_site();
1125 let err = scheduler::validate_kernel_builtin_pair(None, Some(span)).unwrap_err();
1126 assert!(
1127 err.to_string()
1128 .contains("`kernel_builtin_disable` set without `kernel_builtin_enable`"),
1129 "expected disable-without-enable diagnostic, got: {err}"
1130 );
1131 }
1132
1133 // -- validate_exactly_one_source (commit 7c796939) ----------------
1134
1135 #[test]
1136 fn validate_exactly_one_source_none_rejected() {
1137 let span = proc_macro2::Span::call_site();
1138 let err = scheduler::validate_exactly_one_source(false, false, false, span).unwrap_err();
1139 assert!(
1140 err.to_string().contains("no scheduler source declared"),
1141 "expected no-source diagnostic, got: {err}"
1142 );
1143 }
1144
1145 #[test]
1146 fn validate_exactly_one_source_only_binary_passes() {
1147 let span = proc_macro2::Span::call_site();
1148 scheduler::validate_exactly_one_source(true, false, false, span)
1149 .expect("binary-only is valid");
1150 }
1151
1152 #[test]
1153 fn validate_exactly_one_source_only_binary_path_passes() {
1154 let span = proc_macro2::Span::call_site();
1155 scheduler::validate_exactly_one_source(false, true, false, span)
1156 .expect("binary_path-only is valid");
1157 }
1158
1159 #[test]
1160 fn validate_exactly_one_source_only_kernel_builtin_passes() {
1161 let span = proc_macro2::Span::call_site();
1162 scheduler::validate_exactly_one_source(false, false, true, span)
1163 .expect("kernel_builtin-only is valid");
1164 }
1165
1166 #[test]
1167 fn validate_exactly_one_source_binary_and_path_rejected() {
1168 let span = proc_macro2::Span::call_site();
1169 let err = scheduler::validate_exactly_one_source(true, true, false, span).unwrap_err();
1170 assert!(
1171 err.to_string()
1172 .contains("more than one scheduler source declared"),
1173 "expected multi-source diagnostic, got: {err}"
1174 );
1175 }
1176
1177 #[test]
1178 fn validate_exactly_one_source_all_three_rejected() {
1179 let span = proc_macro2::Span::call_site();
1180 let err = scheduler::validate_exactly_one_source(true, true, true, span).unwrap_err();
1181 assert!(
1182 err.to_string()
1183 .contains("more than one scheduler source declared"),
1184 "expected multi-source diagnostic, got: {err}"
1185 );
1186 }
1187
1188 // -- validate_kernel_name_collision (commit 7480df1c) -------------
1189
1190 #[test]
1191 fn validate_kernel_name_collision_non_kernel_passes() {
1192 let expr: syn::Expr = syn::parse_quote!("scx_mitosis");
1193 scheduler::validate_kernel_name_collision(true, "scx_mitosis", Some(&expr))
1194 .expect("non-`kernel` name is valid");
1195 }
1196
1197 #[test]
1198 fn validate_kernel_name_collision_not_kernel_builtin_passes() {
1199 let expr: syn::Expr = syn::parse_quote!("kernel");
1200 scheduler::validate_kernel_name_collision(false, "kernel", Some(&expr))
1201 .expect("`kernel` name is valid when not KernelBuiltin variant");
1202 }
1203
1204 #[test]
1205 fn validate_kernel_name_collision_exact_kernel_rejected() {
1206 let expr: syn::Expr = syn::parse_quote!("kernel");
1207 let err =
1208 scheduler::validate_kernel_name_collision(true, "kernel", Some(&expr)).unwrap_err();
1209 assert!(
1210 err.to_string()
1211 .contains("collides with the KernelBuiltin variant's display_name"),
1212 "expected collision diagnostic, got: {err}"
1213 );
1214 }
1215
1216 #[test]
1217 fn validate_kernel_name_collision_case_insensitive_rejected() {
1218 let expr: syn::Expr = syn::parse_quote!("Kernel");
1219 let err =
1220 scheduler::validate_kernel_name_collision(true, "Kernel", Some(&expr)).unwrap_err();
1221 assert!(
1222 err.to_string()
1223 .contains("collides with the KernelBuiltin variant's display_name"),
1224 "expected case-insensitive collision diagnostic, got: {err}"
1225 );
1226 }
1227
1228 #[test]
1229 fn validate_kernel_name_collision_whitespace_padded_rejected() {
1230 let expr: syn::Expr = syn::parse_quote!(" Kernel ");
1231 let err =
1232 scheduler::validate_kernel_name_collision(true, " Kernel ", Some(&expr)).unwrap_err();
1233 assert!(
1234 err.to_string()
1235 .contains("collides with the KernelBuiltin variant's display_name"),
1236 "expected whitespace-insensitive collision diagnostic, got: {err}"
1237 );
1238 }
1239
1240 // -- validate_payload_workloads_dedup (commit 26352fd7) -----------
1241
1242 #[test]
1243 fn validate_payload_workloads_dedup_empty_workloads_passes() {
1244 let payload: Option<syn::Path> = Some(syn::parse_quote!(FIO));
1245 ktstr_test::validate_payload_workloads_dedup(&payload, &[])
1246 .expect("empty workloads is valid");
1247 }
1248
1249 #[test]
1250 fn validate_payload_workloads_dedup_disjoint_passes() {
1251 let payload: Option<syn::Path> = Some(syn::parse_quote!(FIO));
1252 let workloads: Vec<syn::Path> =
1253 vec![syn::parse_quote!(STRESS_NG), syn::parse_quote!(NETPERF)];
1254 ktstr_test::validate_payload_workloads_dedup(&payload, &workloads)
1255 .expect("disjoint workloads is valid");
1256 }
1257
1258 #[test]
1259 fn validate_payload_workloads_dedup_primary_in_workloads_rejected() {
1260 let payload: Option<syn::Path> = Some(syn::parse_quote!(FIO));
1261 let workloads: Vec<syn::Path> = vec![syn::parse_quote!(STRESS_NG), syn::parse_quote!(FIO)];
1262 let err = ktstr_test::validate_payload_workloads_dedup(&payload, &workloads).unwrap_err();
1263 assert!(
1264 err.to_string().contains("appears in both"),
1265 "expected payload-in-workloads diagnostic, got: {err}"
1266 );
1267 }
1268
1269 #[test]
1270 fn validate_payload_workloads_dedup_pairwise_duplicate_rejected() {
1271 let payload: Option<syn::Path> = None;
1272 let workloads: Vec<syn::Path> = vec![syn::parse_quote!(FIO), syn::parse_quote!(FIO)];
1273 let err = ktstr_test::validate_payload_workloads_dedup(&payload, &workloads).unwrap_err();
1274 assert!(
1275 err.to_string().contains("appears twice"),
1276 "expected pairwise-dupe diagnostic, got: {err}"
1277 );
1278 }
1279}