1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
//! End-to-end: `#[ktstr_test(host_only)]` runs on the real host
//! and sees the real-host topology (not the synthesized 2-CPU
//! placeholder from `KtstrTestEntry::DEFAULT.topology`).
//!
//! Pins the swap from `TestTopology::from_vm_topology(&entry.topology)`
//! to `TestTopology::from_system()?` inside
//! `crate::test_support::dispatch::run_host_only_test_inner`.
//!
//! The `from_vm_topology` default topology resolves to 2 CPUs (1
//! LLC × 2 cores × 1 thread, per the macro defaults at
//! `ktstr-macros::DEFAULT_LLCS`/`DEFAULT_CORES`/`DEFAULT_THREADS`).
//! Asserting `cpu_count >= 4` here catches the regression
//! reliably across every CI runner the project supports — GHA
//! ubuntu-latest is 2-4 CPUs (marginal but typically the project
//! gates host_only tests behind sufficient runners), Apple
//! Silicon CI is 8-core, and dev hosts ≥ 8. A regression that
//! fell back to
//! `from_vm_topology` would report exactly 2 and trip the floor.
//!
//! Also covers the `WorkSpec::workers_pct(1.0)` opt-in scaling
//! contract. (The `KTSTR_HOST_CGROUP_PARENT` default-resolution
//! cascade moved to unit tests in `dispatch.rs` —
//! `resolve_host_cgroup_parent_*` — since it's pure env+const logic
//! that needs no VM and ignored `ctx`.)
use anyhow::Context;
use ktstr::assert::AssertResult;
use ktstr::ktstr_test;
use ktstr::scenario::Ctx;
/// Real-host topology must show ≥ 4 CPUs on any runner the project
/// supports. A regression that fell back to
/// `from_vm_topology(&entry.topology)` would yield exactly 2 CPUs
/// (1 LLC × 2 cores × 1 thread, per `KtstrTestEntry::DEFAULT.topology`
/// derived from macro defaults at
/// `ktstr-macros::DEFAULT_LLCS`/`DEFAULT_CORES`/`DEFAULT_THREADS`).
/// The lower bound is portable to every project-supported runner;
/// raising it from 2 to 4 ensures the synth-topology regression
/// (2-CPU report) is caught reliably.
#[ktstr_test(host_only)]
fn host_mode_sees_real_host_cpus(ctx: &Ctx) -> Result<AssertResult, anyhow::Error> {
// Detection signal that survives no_perf_mode CPU-pinning (which
// narrows the test process to 1-2 host CPUs and would make
// `ctx.topo.all_cpus().len() == 2` coincide with the
// `from_vm_topology` fallback signature):
//
// from_vm_topology synthesizes CPU IDs starting at 0
// (contiguous: 0, 1, ...).
// from_system reads sysfs-online CPUs (likely non-contiguous,
// especially after sched_getaffinity narrowing to a host pin).
//
// Cross-check the topo's CPU IDs against /sys/devices/system/cpu/online
// (which bypasses sched_getaffinity). If every topo CPU is a member
// of the sysfs-online set AND at least one topo CPU id > 1, the
// routing went through from_system (synth would produce IDs 0..n
// only). If the topo is exactly {0, 1} AND sysfs has > 2 CPUs, the
// routing fell back to from_vm_topology (the regression).
let topo_cpus: std::collections::BTreeSet<usize> =
ctx.topo.all_cpus().iter().copied().collect();
let sysfs_online = std::fs::read_to_string("/sys/devices/system/cpu/online")
.context("read /sys/devices/system/cpu/online")?;
let sysfs_cpus =
parse_cpu_list(sysfs_online.trim()).context("parse /sys/devices/system/cpu/online")?;
if sysfs_cpus.len() < 4 {
// Test environment limitation: cargo-ktstr's no_perf_mode +
// mount-namespace sandbox can virtualize
// /sys/devices/system/cpu/online down to the test process's
// CPU pin (typically 1-2 CPUs). In that env we cannot reliably
// distinguish a correct from_system routing from a regressed
// from_vm_topology fallback (both can return 2). SKIP — not
// PASS — so this env is not counted as a run that actually
// verified the routing; a real-host CI runner (>= 4 sysfs CPUs)
// still gates the regression below.
return Ok(AssertResult::skip(format!(
"sysfs reports {} CPUs (< 4) in this test env (sandboxed \
cargo-ktstr context with narrowed /sys); the from_system \
routing regression check cannot reliably trigger. Run on a \
real-host CI with >= 4 visible sysfs CPUs to gate.",
sysfs_cpus.len(),
)));
}
// Distinguishing signal: the synth topo produces CPU ids
// starting at 0 (1×2×1 → {0, 1}). On a >= 4-CPU host where
// no_perf_mode narrowed the test process to 2 CPUs, those 2
// CPUs are almost-never {0, 1} (sysfs-online typically starts at
// 0 but the no_perf_mode pinner chooses based on hash/round-
// robin across the available host CPU pool — the chance of
// hitting {0, 1} on a 316-CPU host is ~2/316²). Detect the
// regression when topo CPUs are exactly {0, 1} AND sysfs has
// more than 2 CPUs.
if topo_cpus == std::collections::BTreeSet::from([0, 1]) && sysfs_cpus.len() > 2 {
return Ok(AssertResult::fail_msg(format!(
"host_only routed through `from_vm_topology` instead of \
`from_system`: ctx.topo.all_cpus() = {{0, 1}} matches \
KtstrTestEntry::DEFAULT.topology (1 LLC × 2 cores × 1 \
thread) exactly while the real host has {} online CPUs.",
sysfs_cpus.len(),
)));
}
// Every topo CPU id MUST be a member of the sysfs-online set —
// catches a regression where from_vm_topology synthesizes IDs
// that don't correspond to real CPUs (e.g. a 1×8×1 default
// would produce 0..=7 even on a 316-CPU host with no_perf_mode
// pinning to {16, 17}).
let sysfs_set: std::collections::BTreeSet<usize> = sysfs_cpus.iter().copied().collect();
let synth_ids: Vec<usize> = topo_cpus.difference(&sysfs_set).copied().collect();
if !synth_ids.is_empty() {
return Ok(AssertResult::fail_msg(format!(
"ctx.topo.all_cpus() contains IDs {synth_ids:?} not present \
in /sys/devices/system/cpu/online (sysfs_count = {}); \
host_only must derive its topology from real sysfs CPUs \
— synthesized IDs indicate a from_vm_topology routing.",
sysfs_cpus.len(),
)));
}
Ok(AssertResult::pass())
}
/// Parse the kernel's `/sys/devices/system/cpu/online` cpu-list
/// format ("0-3,7,10-12") into a Vec of CPU IDs. Used to read the
/// real-host online-CPU set directly without going through
/// `sched_getaffinity` (which the test process may have narrowed
/// under no_perf_mode CPU-pinning).
///
/// Returns `Err` on any malformed token rather than silently
/// skipping — the kernel never emits malformed lines today and a
/// silent skip would let a truncated / corrupt sysfs read (e.g.
/// `unwrap_or(0)` mapping "foo-bar" to {0}) trigger the
/// early-PASS branch above OR the topo-vs-sysfs membership
/// mismatch below with a misleading diagnostic. Per the
/// no-silent-drops policy, surfacing the parse failure here
/// gives the test author an accurate signal instead of a
/// downstream contradiction.
fn parse_cpu_list(s: &str) -> anyhow::Result<Vec<usize>> {
use anyhow::anyhow;
let mut cpus = Vec::new();
for range in s.split(',') {
let range = range.trim();
if range.is_empty() {
continue;
}
match range.split_once('-') {
Some((lo, hi)) => {
let lo: usize = lo
.trim()
.parse()
.map_err(|e| anyhow!("cpu-list range lo {lo:?}: {e}"))?;
let hi: usize = hi
.trim()
.parse()
.map_err(|e| anyhow!("cpu-list range hi {hi:?}: {e}"))?;
if hi < lo {
return Err(anyhow!(
"cpu-list range {lo}-{hi}: hi < lo (kernel emits monotonic ranges)"
));
}
for c in lo..=hi {
cpus.push(c);
}
}
None => {
let c: usize = range
.parse()
.map_err(|e| anyhow!("cpu-list singleton {range:?}: {e}"))?;
cpus.push(c);
}
}
}
Ok(cpus)
}
/// Pin the opt-in scaling contract end-to-end:
/// `WorkSpec::workers_pct(1.0)` in host_only mode resolves to
/// `usable_cpuset().len()` because host_only routes topology through
/// `TestTopology::from_system`. That is the host CPU count minus the
/// one CPU reserved for the root cgroup when the host has more than 2
/// CPUs (`usable_cpus` returns `cpus[..len - 1]` for `len > 2`, all
/// CPUs otherwise). The internal `WorkSpec::resolve_workers_pct` is
/// `pub(crate)` — not callable from an integration test — so this pin
/// reduces to the upstream invariant: the size the ctx exposes as
/// `usable_cpuset()` is what `apply_setup` feeds `resolve_workers_pct`
/// as its `cpuset_cpus` argument when the CgroupDef inherits the
/// topology-default cpuset. `ceil(usable_cpuset().len() * 1.0)` then
/// equals `usable_cpuset().len()`, so a workers_pct(1.0) workload
/// spawns one worker per usable CPU.
#[ktstr_test(host_only)]
fn host_mode_workers_pct_scales_to_host_cpu_count(
ctx: &Ctx,
) -> Result<AssertResult, anyhow::Error> {
let all_cpus = ctx.topo.all_cpus().len();
let usable = ctx.topo.usable_cpuset().len();
if all_cpus < 4 {
// Test environment limitation: cargo-ktstr's no_perf_mode +
// mount-namespace sandbox narrows sysfs and sched_getaffinity
// such that `ctx.topo.all_cpus()` reports the test's pinned
// CPU count (typically 1-2), not the real host's CPU count.
// Under that narrowing the workers_pct(1.0) scaling cannot
// be distinguished from the `from_vm_topology` default-topology
// fallback. SKIP — not PASS — so this env is not counted as a
// passing run; the sibling test `host_mode_sees_real_host_cpus`
// gates the from_system routing regression via
// /sys/devices/system/cpu/online cross-check that bypasses
// the affinity-narrowing layer.
return Ok(AssertResult::skip(format!(
"ctx.topo reports {all_cpus} CPUs (< 4) in this test env \
(sandboxed cargo-ktstr context with narrowed \
sched_getaffinity); the workers_pct scaling check cannot \
reliably trigger. Run on a real-host CI with >= 4 visible \
CPUs to gate."
)));
}
// Real multi-CPU host visible (>= 4). `usable_cpus` reserves the
// last CPU for the root cgroup when the host has > 2 CPUs, so the
// usable set is exactly `all_cpus - 1`. workers_pct(1.0) resolves
// to `usable_cpuset().len()` (the cpuset `apply_setup` feeds
// `resolve_workers_pct`), so pinning `usable == all_cpus - 1` with
// `all_cpus >= 4` proves host_only routed topology through
// `from_system` (the real host count), not the VM-default fallback
// — and that a workers_pct(1.0) workload therefore spawns one
// worker per usable host CPU.
let expected_usable = all_cpus - 1;
if usable != expected_usable {
return Ok(AssertResult::fail_msg(format!(
"ctx.topo.usable_cpuset().len()={usable} != all_cpus().len()-1={expected_usable} \
(the last CPU is reserved for the root cgroup when > 2 CPUs). The \
usable set diverged from the topology default — an operator CPU \
restriction (KTSTR_NO_PERF_MODE / KTSTR_CPU_CAP / a sched_setaffinity \
policy) intersected it. Unset the restriction or skip this test."
)));
}
Ok(AssertResult::pass())
}