ktstr 0.23.0

Test harness for Linux process schedulers
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
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
//! CPU-topology + online-CPU parsing, wprof spawn, and the sys-ready handshake.
//!
//! Split from rust_init.rs; the shared consts/statics/imports live in the
//! parent module (`super`), reached via the glob below.
use super::*;

/// Print the topology line for the shell MOTD.
///
/// Parses KTSTR_TOPO=N,L,C,T from /proc/cmdline (passed by the host).
/// Falls back to counting online CPUs via /sys/devices/system/cpu/online.
pub(crate) fn print_topology_line() {
    if let Some((n, l, c, t)) = parse_topo_from_cmdline() {
        let total = l * c * t;
        if n > 1 {
            println!(
                "  topology:  {n} NUMA nodes, {l} LLC{}, {c} core{}, {t} thread{} ({total} vCPU{})",
                if l == 1 { "" } else { "s" },
                if c == 1 { "" } else { "s" },
                if t == 1 { "" } else { "s" },
                if total == 1 { "" } else { "s" },
            );
        } else {
            println!(
                "  topology:  {l} LLC{}, {c} core{}, {t} thread{} ({total} vCPU{})",
                if l == 1 { "" } else { "s" },
                if c == 1 { "" } else { "s" },
                if t == 1 { "" } else { "s" },
                if total == 1 { "" } else { "s" },
            );
        }
    } else if let Some(count) = count_online_cpus() {
        println!(
            "  topology:  {count} vCPU{}",
            if count == 1 { "" } else { "s" }
        );
    }
}

/// Parse KTSTR_TOPO=N,L,C,T from /proc/cmdline.
pub(crate) fn parse_topo_from_cmdline() -> Option<(u32, u32, u32, u32)> {
    let val = cmdline_val("KTSTR_TOPO")?;
    let parts: Vec<&str> = val.split(',').collect();
    if parts.len() != 4 {
        return None;
    }
    let n: u32 = parts[0].parse().ok()?;
    let l: u32 = parts[1].parse().ok()?;
    let c: u32 = parts[2].parse().ok()?;
    let t: u32 = parts[3].parse().ok()?;
    Some((n, l, c, t))
}

#[cfg(feature = "wprof")]
/// Spawn `/bin/wprof` in a background thread if the host set
/// `KTSTR_WPROF_ARGS` on the kernel cmdline. Returns a join handle
/// whose `.join()` yields `Some(Vec<u8>)` (the `.pb` trace bytes)
/// on success, or `None` on failure / no-op.
///
/// The spawned thread:
/// 1. Parses `KTSTR_WPROF_ARGS` from `/proc/cmdline`
/// 2. Runs `/bin/wprof <args> -T /tmp/wprof.pb -D /tmp/wprof.data`
/// 3. Waits for the process to exit
/// 4. Reads `/tmp/wprof.pb` and returns the bytes
///
/// If `KTSTR_WPROF_ARGS` is absent or `/bin/wprof` doesn't exist,
/// returns `None` (no thread spawned, no-op). The caller joins the
/// handle after the test workload dispatch returns and ships the
/// bytes via [`crate::vmm::guest_comms::send_wprof_trace`].
pub(crate) fn spawn_wprof_if_configured() -> Option<std::thread::JoinHandle<Option<Vec<u8>>>> {
    let args_str = cmdline_val("KTSTR_WPROF_ARGS")?;
    let wprof_bin = std::path::Path::new("/bin/wprof");
    if !wprof_bin.exists() {
        tracing::warn!("KTSTR_WPROF_ARGS set but /bin/wprof missing from initramfs");
        return None;
    }
    Some(
        std::thread::Builder::new()
            .name("wprof-capture".into())
            .spawn(move || {
                // Host encodes args with ASCII Unit Separator (\x1F)
                // via `WprofConfig::args_cmdline` because kernel
                // cmdline tokenization would truncate a space-joined
                // value at the first space. Split on the same
                // delimiter here to recover the per-arg vec.
                let mut cmd_args: Vec<String> = args_str.split('\x1f').map(String::from).collect();
                cmd_args.extend([
                    "-T".to_string(),
                    "/tmp/wprof.pb".to_string(),
                    "-D".to_string(),
                    "/tmp/wprof.data".to_string(),
                ]);
                tracing::debug!(args = ?cmd_args, "spawning /bin/wprof");
                // Bounded wait for the wprof child. A wprof stranded by a
                // crashing sched_ext scheduler — notably the 6.14
                // bypass-drain stall after scx_bpf_error, where runnable
                // tasks (including wprof's own) are not reliably migrated to
                // fair — never reaches its capture-window exit, so a
                // blocking `.status()` here would wedge guest teardown: no
                // reboot, no wprof ship, no SCHED_EXIT, the guest just hangs
                // to the host watchdog. Spawn + wait on the child's pidfd up
                // to a deadline sized off the `-d` capture window (ms) plus a
                // generous processing margin, kept under the host
                // `WPROF_SHIP_GRACE` (30s) so the guest kills a wedged wprof
                // and reboots within the host's grace. On the cap the trace
                // is dropped (loudly) and teardown proceeds — a clean
                // failure, never a hang. Mirrors `reap_child_bounded`.
                let capture_ms = cmd_args
                    .iter()
                    .position(|a| a == "-d")
                    .and_then(|i| cmd_args.get(i + 1))
                    .and_then(|d| d.parse::<u64>().ok())
                    .unwrap_or(500);
                let deadline = std::time::Duration::from_millis(capture_ms)
                    + std::time::Duration::from_secs(10);
                let mut child = match std::process::Command::new("/bin/wprof")
                    .args(&cmd_args)
                    .stdout(std::process::Stdio::null())
                    .stderr(std::process::Stdio::inherit())
                    .spawn()
                {
                    Ok(c) => c,
                    Err(e) => {
                        tracing::warn!(%e, "spawn /bin/wprof failed");
                        return None;
                    }
                };
                // Determine wprof's exit disposition, then ship the trace on
                // FILE-PRESENCE (a non-empty /tmp/wprof.pb) regardless of exit
                // disposition. wprof fflushes the COMPLETE .pb as the last step
                // before main returns, so a wprof that finished its post-window
                // emit but is slow or nonzero to EXIT — e.g. its userspace emit
                // thread lost CPU to the concurrent auto-repro probe pipeline
                // during the crash-bypass window — still yields a valid trace
                // (the host validates shape via assert_wprof_pb_shape). On a
                // timeout, SIGTERM first (wprof's handler sets its `exiting`
                // flag, letting an in-progress emit flush), then a bounded
                // secondary wait, then SIGKILL, so a nearly-done emit is not
                // truncated. This is the reliability backstop; it perturbs no
                // scheduling priority.
                let pid = child.id() as libc::pid_t;
                match crate::sync::pidfd_poll_exited(pid, deadline) {
                    crate::sync::PidfdWait::Exited => {
                        let _ = child.wait();
                    }
                    crate::sync::PidfdWait::TimedOut => {
                        // wprof did not exit within the window — its userspace
                        // emit thread lost CPU (e.g. to the concurrent auto-repro
                        // probe pipeline during the crash-bypass window). SIGTERM
                        // first: wprof's handler sets its `exiting` flag, letting
                        // an in-progress emit fflush the .pb, then reap with a
                        // bounded second wait, then hard-kill. The .pb read below
                        // still recovers a completed-but-slow-to-exit emit.
                        eprintln!(
                            "ktstr: wprof exceeded {deadline:?}; SIGTERM to let its \
                             emit flush, then reap"
                        );
                        // SAFETY: `pid` is this thread's own child; SIGTERM to a
                        // live pid is well-defined and ESRCH on an already-reaped
                        // pid is harmless (return value dropped).
                        unsafe {
                            libc::kill(pid, libc::SIGTERM);
                        }
                        match crate::sync::pidfd_poll_exited(
                            pid,
                            std::time::Duration::from_secs(8),
                        ) {
                            crate::sync::PidfdWait::Exited => {
                                let _ = child.wait();
                            }
                            _ => {
                                let _ = child.kill();
                                let _ = child.wait();
                            }
                        }
                    }
                    crate::sync::PidfdWait::NoPidfd => {
                        if !matches!(child.try_wait(), Ok(Some(_))) {
                            let _ = child.kill();
                        }
                        let _ = child.wait();
                    }
                }
                match std::fs::read("/tmp/wprof.pb") {
                    Ok(bytes) if !bytes.is_empty() => {
                        tracing::debug!(pb_bytes = bytes.len(), "wprof trace captured");
                        Some(bytes)
                    }
                    Ok(_) => {
                        eprintln!("ktstr: wprof produced no trace this run (/tmp/wprof.pb empty)");
                        None
                    }
                    Err(e) => {
                        eprintln!("ktstr: wprof produced no trace this run (/tmp/wprof.pb absent/unreadable: {e})");
                        None
                    }
                }
            })
            .expect("spawn wprof-capture thread"),
    )
}

/// Loop-iteration counter for [`send_sys_rdy_with_retry`], bumped once
/// per retry iteration. Exists so a test can pin that the fast-fail
/// retry path THROTTLES (bounded iterations) rather than hot-spinning:
/// with the guard-rail sleep, a port-exists + always-failing-send run
/// makes roughly `budget / 100ms` iterations, whereas a regression that
/// dropped the throttle would make thousands. Mirrors the
/// observability-counter pattern of
/// [`crate::vmm::guest_comms::BULK_PORT_WRITE_ATTEMPTS`], including its
/// `#[cfg(test)]` gating: the counter and its increment compile only
/// under test, never into the production guest-init binary.
#[cfg(test)]
pub(crate) static SEND_SYS_RDY_RETRY_ITERS: std::sync::atomic::AtomicU64 =
    std::sync::atomic::AtomicU64::new(0);

/// Wait (event-driven) for the virtio-console bulk port, then deliver
/// the KERN_ADDRS + SYS_RDY frames to the host.
///
/// The kernel virtio_console driver's multiport handshake
/// (DEVICE_READY → PORT_ADD → PORT_READY → PORT_OPEN, see
/// `drivers/char/virtio_console.c`) completes asynchronously in two
/// stages this function waits on WITHOUT polling:
///
/// 1. **Node appearance.** `/dev/vport0p1` is created by `add_port`'s
///    `device_create` when PORT_ADD arrives. We block on
///    `kernfs_evented_wait` over an inotify watch of the port's
///    parent directory (`IN_CREATE` / `IN_MOVED_TO`) so we wake on the
///    exact devtmpfs create edge, with a 1 s guard-rail cadence
///    bounding wake latency if the best-effort inotify source misses.
///    This replaces a former 100 ms existence poll whose stacked sleep
///    latency on a cold / oversubscribed boot delayed KERN_ADDRS (and
///    thus the host's BPF-accessor build) well past the workload.
///
/// 2. **Host-connected.** `host_connected` flips true only when
///    PORT_OPEN arrives later. The send path's blocking writev is
///    itself kernel-evented: it parks in `wait_port_writable` →
///    `wait_event_freezable(port->waitqueue, !will_write_block)` (no
///    timeout) until PORT_OPEN's `wake_up_interruptible` fires. So no
///    poll/sleep is needed for stage 2 — `send_kern_addrs` /
///    `send_sys_rdy` simply block until writable. (`poll(POLLOUT)` is
///    NOT usable: `port_fops_poll` reports `EPOLLHUP` while
///    `!host_connected`, so a POLLOUT wait would busy-return.)
///
/// The deadline is captured INSIDE the function so guest-init setup
/// (mounts, kallsyms reads) does not eat the handshake's budget.
///
/// Both KERN_ADDRS and SYS_RDY are required for the host. KERN_ADDRS
/// is latched: once a `send_kern_addrs` call returns true the retry
/// skips it. The early-return condition is `kern_addrs_sent &&
/// send_sys_rdy()` — we never exit until BOTH have been delivered (a
/// successful sys_rdy on a re-opened FD after a kern_addrs failure must
/// not leave kern_addrs unsent, because the host's KERN_ADDRS arm is
/// the only virt-KASLR publisher on aarch64; see
/// `src/vmm/freeze_coord/dispatch.rs`'s KERN_ADDRS handler). A send
/// that fails re-enters stage 1 — whose fast-path returns immediately
/// while the node exists — and retries, bounded by the deadline. Most
/// send failures block first (the writev parks in `wait_port_writable`
/// until PORT_OPEN), but the open-error and post-connect fast-error
/// paths (a `try_open` failure, or an ENOMEM / add-outbuf error after
/// host_connected) return WITHOUT parking, so a bounded throttle guards
/// the retry against hot-spinning the guest init thread (matching the
/// sibling `send_sched_swap_notify` / `send_scenario_start` loops).
///
/// Host idempotency for KERN_ADDRS retries (matters when the latch
/// is reset by a failed write that cleared the cached FD):
/// `kern_phys_base` uses `.store(Release)` (overwrites every
/// CRC-valid frame) and `kern_virt_kaslr` uses CAS-once. The
/// payload bytes are identical across retries (built once from
/// `KernAddrs::new`), so repeated stores and a CAS-success-then-
/// no-op-on-equal-existing both produce the same final state.
///
/// On budget exhaustion (or a kernel with no evented source available
/// for stage 1) the function emits a structured WARN with fields
/// `budget_ms`, `vcpus`, `elapsed_ms` (loop wall time), `port_exists`
/// (sampled once before WARN), and `kern_addrs_sent`. The guest then
/// continues — the host monitor's `data_valid` gate keeps reads safe
/// without SYS_RDY, and the freeze coordinator's `Option::take` makes a
/// late SYS_RDY harmless (fire-once). See
/// `doc/guide/src/troubleshooting.md#send_sys_rdy-timeout` for the
/// operator-facing diagnosis flow.
pub(crate) fn send_sys_rdy_with_retry(
    budget: std::time::Duration,
    vcpus: u32,
    kern_addrs: &crate::vmm::wire::KernAddrs,
    port_path: &std::path::Path,
) {
    use crate::vmm::freeze_coord::evented_wait::{KernfsWaitOutcome, kernfs_evented_wait};
    use nix::sys::inotify::AddWatchFlags;

    let loop_t0 = std::time::Instant::now();
    let deadline = loop_t0 + budget;
    // Guard-rail cadence bounding wake latency ONLY if the best-effort
    // inotify source misses the device-create edge; the real wake is
    // the inotify event. Not a poll — see `kernfs_evented_wait`.
    let cadence = std::time::Duration::from_secs(1);
    // The port node lives directly under /dev; watch that directory for
    // its creation. `/dev` is the parent of `/dev/vport0p1`.
    let watch_dir = port_path
        .parent()
        .unwrap_or_else(|| std::path::Path::new("/dev"));

    // Structured WARN shared by the timeout and no-evented-source exits.
    // Snapshots `port_exists` so the field reports the last-attempt
    // state, not a fresh stat that could observe the port appearing in
    // the gap between the final wait and the WARN call.
    let warn_timeout = |kern_addrs_sent: bool| {
        let port_exists_snapshot = port_path.exists();
        tracing::warn!(
            budget_ms = budget.as_millis() as u64,
            vcpus,
            elapsed_ms = loop_t0.elapsed().as_millis() as u64,
            port_exists = port_exists_snapshot,
            kern_addrs_sent,
            "ktstr-init: send_sys_rdy failed within boot budget; \
             see https://ktstr.dev/guide/troubleshooting.html#send_sys_rdy-timeout",
        );
    };

    let mut kern_addrs_sent = false;
    loop {
        #[cfg(test)]
        SEND_SYS_RDY_RETRY_ITERS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
        // Stage 1: block (evented) until `/dev/vport0p1` appears. On a
        // send-failure retry the node already exists, so the fast-path
        // predicate returns immediately.
        match kernfs_evented_wait(
            watch_dir,
            AddWatchFlags::IN_CREATE | AddWatchFlags::IN_MOVED_TO,
            None::<&std::path::Path>,
            cadence,
            deadline,
            || port_path.exists().then_some(()),
        ) {
            KernfsWaitOutcome::Done(()) => {
                // Stage 2: the sends' blocking writev parks on the port
                // waitqueue until PORT_OPEN (host_connected) — kernel-
                // evented, no poll/sleep needed here.
                if !kern_addrs_sent {
                    kern_addrs_sent = crate::vmm::guest_comms::send_kern_addrs(kern_addrs);
                }
                if kern_addrs_sent && crate::vmm::guest_comms::send_sys_rdy() {
                    return;
                }
                if std::time::Instant::now() >= deadline {
                    warn_timeout(kern_addrs_sent);
                    return;
                }
                // The send failed and reset the cached FD. Most send
                // failures block first (the writev parks in
                // wait_port_writable until PORT_OPEN), but the
                // open-error and post-connect fast-error paths (a
                // try_open failure, or an ENOMEM / add-outbuf error
                // after host_connected) return WITHOUT parking. Throttle
                // before retrying so a persistent fast-fail cannot
                // hot-spin the guest init thread — a bounded guard-rail
                // capped by the remaining budget, matching the sibling
                // bulk-port retry loops (send_sched_swap_notify /
                // send_scenario_start). Stage 1's node-appearance wait
                // stays fully evented.
                let remaining = deadline.saturating_duration_since(std::time::Instant::now());
                std::thread::sleep(std::time::Duration::from_millis(100).min(remaining));
            }
            KernfsWaitOutcome::Timeout | KernfsWaitOutcome::NoEventedSource => {
                warn_timeout(kern_addrs_sent);
                return;
            }
        }
    }
}

/// Count online CPUs from /sys/devices/system/cpu/online.
///
/// The file contains a range list like "0-3" or "0-1,3". Parse and
/// count individual CPUs.
pub(crate) fn count_online_cpus() -> Option<u32> {
    let content = fs::read_to_string("/sys/devices/system/cpu/online").ok()?;
    parse_online_cpus(&content)
}

/// Parse a cpulist string (kernel `/sys/.../online` format) and
/// return the total count of CPUs it covers. Comma-separated tokens,
/// each either a single index or a `start-end` inclusive range.
/// Returns `None` on any unparseable token, inverted range, or
/// completely empty content. The `sys_rdy` budget caller at
/// [`count_online_cpus`]'s primary use defaults to 1 vCPU on `None`
/// (safe degradation to the single-vCPU budget); the topology-print
/// caller skips the MOTD line instead of substituting a default.
pub(crate) fn parse_online_cpus(content: &str) -> Option<u32> {
    let trimmed = content.trim();
    if trimmed.is_empty() {
        return None;
    }
    let mut count = 0u32;
    for range in trimmed.split(',') {
        if let Some((start, end)) = range.split_once('-') {
            let s: u32 = start.parse().ok()?;
            let e: u32 = end.parse().ok()?;
            count = count.checked_add(e.checked_sub(s)?.checked_add(1)?)?;
        } else {
            let _: u32 = range.parse().ok()?;
            count = count.checked_add(1)?;
        }
    }
    Some(count)
}

/// Print the include-files line for the shell MOTD.
///
/// Scans /include-files/ and lists each entry. Executable files
/// are marked with "(executable)".
pub(crate) fn print_includes_line() {
    let include_dir = Path::new("/include-files");
    if !include_dir.is_dir() {
        return;
    }
    let mut files: Vec<(String, bool)> = Vec::new();
    // Walk recursively to discover files in nested directories.
    for entry in walkdir::WalkDir::new(include_dir)
        .min_depth(1)
        .sort_by_file_name()
    {
        let Ok(entry) = entry else { continue };
        if !entry.file_type().is_file() {
            continue;
        }
        let rel = entry
            .path()
            .strip_prefix(include_dir)
            .unwrap_or(entry.path());
        let name = rel.to_string_lossy().to_string();
        let executable = entry
            .metadata()
            .map(|m| {
                use std::os::unix::fs::PermissionsExt;
                m.permissions().mode() & 0o111 != 0
            })
            .unwrap_or(false);
        files.push((name, executable));
    }
    if files.is_empty() {
        return;
    }
    for (i, (name, executable)) in files.iter().enumerate() {
        let marker = if *executable { " (executable)" } else { "" };
        let path = format!("/include-files/{name}{marker}");
        if i == 0 {
            println!("  includes:  {path}");
        } else {
            println!("             {path}");
        }
    }
}