ktstr/cli/locks.rs
1//! `ktstr locks` — observational enumeration of every ktstr flock
2//! on the host.
3//!
4//! Troubleshooting companion to `--cpu-cap`: when a build or test is
5//! stalled behind a peer's reservation, `ktstr locks` names the peer
6//! (PID + cmdline) without disturbing any of its flocks. Reads
7//! `{lock_dir}/ktstr-llc-*.lock`, `{lock_dir}/ktstr-cpu-*.lock`
8//! (where `lock_dir` is `KTSTR_LOCK_DIR` or `/tmp`), and
9//! `{cache_root}/.locks/*.lock`; calls [`crate::flock::read_holders`]
10//! once per file, which does a single `/proc/locks` parse internally.
11
12use std::path::Path;
13
14use anyhow::{Result, anyhow};
15
16use crate::cache::CacheDir;
17
18use super::util::new_table;
19
20/// One LLC-lock row in the `ktstr locks` output.
21///
22/// `pub(crate)` so the test-only [`collect_locks_snapshot_from`]
23/// seam can return the type from outside its defining module.
24#[derive(Debug, serde::Serialize)]
25#[serde(rename_all = "snake_case")]
26pub(crate) struct LlcLockRow {
27 pub(crate) llc_idx: usize,
28 pub(crate) numa_node: Option<usize>,
29 pub(crate) lockfile: String,
30 pub(crate) holders: Vec<crate::flock::HolderInfo>,
31}
32
33/// One per-CPU-lock row. `numa_node` carries the host NUMA node the
34/// CPU lives on, looked up via [`crate::vmm::host_topology::HostTopology`]'s
35/// `cpu_to_node` map; `None` when the sysfs probe failed and the
36/// host topology is unavailable.
37#[derive(Debug, serde::Serialize)]
38#[serde(rename_all = "snake_case")]
39pub(crate) struct CpuLockRow {
40 pub(crate) cpu: usize,
41 pub(crate) numa_node: Option<usize>,
42 pub(crate) lockfile: String,
43 pub(crate) holders: Vec<crate::flock::HolderInfo>,
44}
45
46/// One cache-entry-lock row. Cache locks live at
47/// `{cache_root}/.locks/{cache_key}.lock`; `cache_key` is parsed
48/// from the filename stem.
49#[derive(Debug, serde::Serialize)]
50#[serde(rename_all = "snake_case")]
51pub(crate) struct CacheLockRow {
52 pub(crate) cache_key: String,
53 pub(crate) lockfile: String,
54 pub(crate) holders: Vec<crate::flock::HolderInfo>,
55}
56
57/// One run-dir-lock row. Per-run-key sidecar-write locks live at
58/// `{runs_root}/.locks/{run_key}.lock` where `{run_key}` is the
59/// `{kernel}-{project_commit}` directory name; `run_key` is parsed
60/// from the filename stem (same shape as
61/// [`CacheLockRow::cache_key`] — the row exists as a distinct type
62/// so the "this lock serializes sidecar writes" semantic is
63/// visible at the schema level rather than buried under the cache
64/// table's heading).
65#[derive(Debug, serde::Serialize)]
66#[serde(rename_all = "snake_case")]
67pub(crate) struct RunDirLockRow {
68 pub(crate) run_key: String,
69 pub(crate) lockfile: String,
70 pub(crate) holders: Vec<crate::flock::HolderInfo>,
71}
72
73/// Snapshot of every ktstr flock discoverable on the host at the
74/// moment this is built. Assembled by [`collect_locks_snapshot`] and
75/// rendered by either the human [`render_locks_human`] or JSON
76/// [`serde_json::to_string_pretty`] path.
77#[derive(Debug, serde::Serialize)]
78#[serde(rename_all = "snake_case")]
79pub(crate) struct LocksSnapshot {
80 pub(crate) llcs: Vec<LlcLockRow>,
81 pub(crate) cpus: Vec<CpuLockRow>,
82 pub(crate) cache: Vec<CacheLockRow>,
83 pub(crate) run_dirs: Vec<RunDirLockRow>,
84}
85
86/// Enumerate every ktstr lockfile reachable on the host, attach the
87/// holder list parsed from `/proc/locks`, and return a structured
88/// snapshot suitable for either human or JSON rendering.
89///
90/// Missing paths (no `/tmp` glob matches, no `cache_root/.locks/`,
91/// no `runs_root/.locks/`) produce empty row vectors — not an
92/// error. The lockfile glob pattern uses the `glob` crate (already
93/// a dep); failures to expand are treated as "no files matched"
94/// and surfaced via `tracing::warn!` so the operator still sees a
95/// populated snapshot for the paths that did work.
96fn collect_locks_snapshot() -> Result<LocksSnapshot> {
97 let cache_root = CacheDir::default_root().ok();
98 let runs_root = crate::test_support::runs_root();
99 let lock_dir = crate::cache::resolve_lock_dir();
100 collect_locks_snapshot_from(&lock_dir, cache_root.as_deref(), Some(&runs_root))
101}
102
103/// Seam behind [`collect_locks_snapshot`]: enumerate LLC, per-CPU,
104/// cache-entry, and per-run-key lockfiles under the given roots.
105/// Tests inject tempdirs for each of `tmp_root`, `cache_root`, and
106/// `runs_root` so the `ktstr locks` snapshot shape can be pinned
107/// without touching the real host `/tmp`, the operator's cache
108/// directory, or the workspace's `target/ktstr/`.
109///
110/// `tmp_root` is the directory containing `ktstr-llc-*.lock` and
111/// `ktstr-cpu-*.lock` (in production: `/tmp`). `cache_root` is the
112/// cache-directory whose `.locks/` subdirectory holds per-entry
113/// locks (in production: `CacheDir::default_root()`); `None`
114/// suppresses the cache-lock enumeration entirely, matching the
115/// "home unresolvable" production fallback. `runs_root` is the
116/// directory whose `.locks/` subdirectory holds per-run-key
117/// sidecar-write locks (in production:
118/// [`crate::test_support::runs_root`]); `None` suppresses run-dir
119/// lock enumeration.
120pub(crate) fn collect_locks_snapshot_from(
121 tmp_root: &Path,
122 cache_root: Option<&Path>,
123 runs_root: Option<&Path>,
124) -> Result<LocksSnapshot> {
125 use crate::vmm::host_topology::HostTopology;
126
127 // Sysfs probe is best-effort — a container without
128 // /sys/devices/system/cpu populated still gets to see its flocks,
129 // just without NUMA node annotation (degrades to `None` in JSON
130 // and `"?"` in the human table). Both the LLC-index→node lookup
131 // (via `llc_numa_node`) and the per-CPU→node lookup (via
132 // `cpu_to_node`) live on HostTopology — no TestTopology needed.
133 let host_topo = HostTopology::from_sysfs().ok();
134
135 // LLC locks: {tmp_root}/ktstr-llc-{N}.lock
136 let llc_pattern = format!("{}/ktstr-llc-*.lock", tmp_root.display());
137 let mut llcs: Vec<LlcLockRow> = Vec::new();
138 for entry in glob::glob(&llc_pattern)
139 .map_err(|e| anyhow!("glob {llc_pattern}: {e}"))?
140 .flatten()
141 {
142 let Some(stem) = entry.file_stem().and_then(|s| s.to_str()) else {
143 continue;
144 };
145 // Stem is like "ktstr-llc-0"; strip prefix to get the index.
146 let Some(idx_str) = stem.strip_prefix("ktstr-llc-") else {
147 continue;
148 };
149 let Ok(llc_idx) = idx_str.parse::<usize>() else {
150 continue;
151 };
152 let holders = crate::flock::read_holders(&entry).unwrap_or_default();
153 let numa_node = host_topo.as_ref().and_then(|t| {
154 if llc_idx < t.llc_groups.len() {
155 Some(t.llc_numa_node(llc_idx))
156 } else {
157 None
158 }
159 });
160 llcs.push(LlcLockRow {
161 llc_idx,
162 numa_node,
163 lockfile: entry.display().to_string(),
164 holders,
165 });
166 }
167 llcs.sort_by_key(|r| r.llc_idx);
168
169 // Per-CPU locks: {tmp_root}/ktstr-cpu-{C}.lock
170 let cpu_pattern = format!("{}/ktstr-cpu-*.lock", tmp_root.display());
171 let mut cpus: Vec<CpuLockRow> = Vec::new();
172 for entry in glob::glob(&cpu_pattern)
173 .map_err(|e| anyhow!("glob {cpu_pattern}: {e}"))?
174 .flatten()
175 {
176 let Some(stem) = entry.file_stem().and_then(|s| s.to_str()) else {
177 continue;
178 };
179 let Some(idx_str) = stem.strip_prefix("ktstr-cpu-") else {
180 continue;
181 };
182 let Ok(cpu) = idx_str.parse::<usize>() else {
183 continue;
184 };
185 let holders = crate::flock::read_holders(&entry).unwrap_or_default();
186 let numa_node = host_topo
187 .as_ref()
188 .and_then(|t| t.cpu_to_node.get(&cpu).copied());
189 cpus.push(CpuLockRow {
190 cpu,
191 numa_node,
192 lockfile: entry.display().to_string(),
193 holders,
194 });
195 }
196 cpus.sort_by_key(|r| r.cpu);
197
198 // Cache-entry locks: {cache_root}/.locks/*.lock — skipped when
199 // `cache_root` is None (unresolvable home / test isolation).
200 // Subdirectory name sourced from `crate::flock::LOCK_DIR_NAME`
201 // so the cache scan and the run-dir scan below stay in sync
202 // with the cache module and sidecar module's on-disk layout.
203 let mut cache: Vec<CacheLockRow> = Vec::new();
204 if let Some(cache_root) = cache_root {
205 let locks_dir = cache_root.join(crate::flock::LOCK_DIR_NAME);
206 let pattern = format!("{}/*.lock", locks_dir.display());
207 if let Ok(expanded) = glob::glob(&pattern) {
208 for entry in expanded.flatten() {
209 let Some(stem) = entry.file_stem().and_then(|s| s.to_str()) else {
210 continue;
211 };
212 let holders = crate::flock::read_holders(&entry).unwrap_or_default();
213 cache.push(CacheLockRow {
214 cache_key: stem.to_string(),
215 lockfile: entry.display().to_string(),
216 holders,
217 });
218 }
219 }
220 }
221 cache.sort_by(|a, b| a.cache_key.cmp(&b.cache_key));
222
223 // Per-run-key sidecar-write locks: {runs_root}/.locks/*.lock —
224 // skipped when `runs_root` is None (test isolation). Mirrors
225 // the cache-lock loop's shape (single-segment file_stem →
226 // run_key), but the row carries a distinct `RunDirLockRow`
227 // type so the JSON surface and human heading distinguish
228 // "this lock serializes sidecar writes" from "this lock
229 // serializes a kernel cache install".
230 let mut run_dirs: Vec<RunDirLockRow> = Vec::new();
231 if let Some(runs_root) = runs_root {
232 let locks_dir = runs_root.join(crate::flock::LOCK_DIR_NAME);
233 let pattern = format!("{}/*.lock", locks_dir.display());
234 if let Ok(expanded) = glob::glob(&pattern) {
235 for entry in expanded.flatten() {
236 let Some(stem) = entry.file_stem().and_then(|s| s.to_str()) else {
237 continue;
238 };
239 let holders = crate::flock::read_holders(&entry).unwrap_or_default();
240 run_dirs.push(RunDirLockRow {
241 run_key: stem.to_string(),
242 lockfile: entry.display().to_string(),
243 holders,
244 });
245 }
246 }
247 }
248 run_dirs.sort_by(|a, b| a.run_key.cmp(&b.run_key));
249
250 Ok(LocksSnapshot {
251 llcs,
252 cpus,
253 cache,
254 run_dirs,
255 })
256}
257
258/// Render a [`LocksSnapshot`] as four stacked comfy-tables for
259/// interactive reading. Empty sections print "(none)" under their
260/// header so the operator can distinguish "no locks of this kind" from
261/// a display bug. NUMA column renders the numeric node when available
262/// or `"?"` when the sysfs probe failed.
263fn render_locks_human(snap: &LocksSnapshot) -> String {
264 use std::fmt::Write;
265 let mut out = String::new();
266
267 let fmt_holders = |hs: &[crate::flock::HolderInfo]| -> String {
268 if hs.is_empty() {
269 crate::flock::NO_HOLDERS_RECORDED.to_string()
270 } else {
271 // Newline-separated so multi-holder lockfile rows
272 // don't wrap mid-cmdline on narrow terminals (the
273 // prior comma-joined form did). Within a comfy-table
274 // cell, each holder now renders on its own line.
275 hs.iter()
276 .map(|h| format!("{} ({})", h.pid, h.cmdline))
277 .collect::<Vec<_>>()
278 .join("\n")
279 }
280 };
281 let fmt_node = |n: Option<usize>| -> String {
282 match n {
283 Some(v) => v.to_string(),
284 None => "?".to_string(),
285 }
286 };
287
288 writeln!(out, "LLC locks:").unwrap();
289 if snap.llcs.is_empty() {
290 writeln!(out, " (none)").unwrap();
291 } else {
292 let mut t = new_table();
293 t.set_header(["LLC", "NODE", "LOCKFILE", "HOLDERS"]);
294 for r in &snap.llcs {
295 t.add_row([
296 r.llc_idx.to_string(),
297 fmt_node(r.numa_node),
298 r.lockfile.clone(),
299 fmt_holders(&r.holders),
300 ]);
301 }
302 writeln!(out, "{t}").unwrap();
303 }
304
305 writeln!(out, "\nPer-CPU locks:").unwrap();
306 if snap.cpus.is_empty() {
307 writeln!(out, " (none)").unwrap();
308 } else {
309 let mut t = new_table();
310 t.set_header(["CPU", "NODE", "LOCKFILE", "HOLDERS"]);
311 for r in &snap.cpus {
312 t.add_row([
313 r.cpu.to_string(),
314 fmt_node(r.numa_node),
315 r.lockfile.clone(),
316 fmt_holders(&r.holders),
317 ]);
318 }
319 writeln!(out, "{t}").unwrap();
320 }
321
322 writeln!(out, "\nCache-entry locks:").unwrap();
323 if snap.cache.is_empty() {
324 writeln!(out, " (none)").unwrap();
325 } else {
326 let mut t = new_table();
327 t.set_header(["CACHE KEY", "LOCKFILE", "HOLDERS"]);
328 for r in &snap.cache {
329 t.add_row([
330 r.cache_key.clone(),
331 r.lockfile.clone(),
332 fmt_holders(&r.holders),
333 ]);
334 }
335 writeln!(out, "{t}").unwrap();
336 }
337
338 writeln!(out, "\nRun-dir locks:").unwrap();
339 if snap.run_dirs.is_empty() {
340 writeln!(out, " (none)").unwrap();
341 } else {
342 let mut t = new_table();
343 t.set_header(["RUN KEY", "LOCKFILE", "HOLDERS"]);
344 for r in &snap.run_dirs {
345 t.add_row([
346 r.run_key.clone(),
347 r.lockfile.clone(),
348 fmt_holders(&r.holders),
349 ]);
350 }
351 writeln!(out, "{t}").unwrap();
352 }
353
354 out
355}
356
357/// Shared kill flag for the `ktstr locks --watch` SIGINT handler.
358/// `libc::signal` installs the C-level handler; the handler flips
359/// this atomic so the redraw loop can exit cleanly between frames
360/// instead of being torn down mid-print. The flag stays set for the
361/// remainder of the process lifetime — `ktstr locks` is a one-shot
362/// observational command, so re-arming is unnecessary.
363static LOCKS_WATCH_KILL: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
364
365/// SIGINT handler for `--watch`: flip the kill flag and return. The
366/// main loop observes the flag between frames (at most one interval
367/// after Ctrl-C) and exits. No buffered output is flushed here —
368/// stdout is line-buffered on a pipe and fully flushed per-frame by
369/// `println!`, so a mid-frame interrupt at worst drops the unwritten
370/// portion of the final table, not the prior frame.
371extern "C" fn locks_watch_sigint_handler(_sig: libc::c_int) {
372 LOCKS_WATCH_KILL.store(true, std::sync::atomic::Ordering::SeqCst);
373}
374
375/// `ktstr locks` entry point. See module-level doc block above.
376///
377/// When `watch` is `Some(interval)`, runs a redraw loop that prints
378/// the snapshot, sleeps `interval`, and repeats until SIGINT. JSON
379/// mode under `--watch` emits one JSON object per interval with a
380/// trailing newline so streaming consumers can read frame-by-frame
381/// via newline-delimited JSON.
382pub fn list_locks(json: bool, watch: Option<std::time::Duration>) -> Result<()> {
383 // One-shot: snapshot, render, done.
384 if watch.is_none() {
385 let snap = collect_locks_snapshot()?;
386 if json {
387 println!("{}", serde_json::to_string_pretty(&snap)?);
388 } else {
389 print!("{}", render_locks_human(&snap));
390 }
391 return Ok(());
392 }
393 let interval = watch.unwrap();
394
395 // Install the SIGINT handler once. `libc::signal` returns the
396 // previous handler; we discard it — `ktstr locks` is a terminal
397 // command, nothing restores the prior handler on exit.
398 // SAFETY: libc::signal is an FFI call with no memory effects.
399 // `locks_watch_sigint_handler` is an `extern "C" fn` with the
400 // correct `void(int)` signature. The handler only writes to a
401 // static AtomicBool, which is async-signal-safe. Cast routes
402 // fn-item → `*const ()` → `sighandler_t` so the
403 // `function_casts_as_integer` lint is satisfied.
404 unsafe {
405 libc::signal(
406 libc::SIGINT,
407 locks_watch_sigint_handler as *const () as libc::sighandler_t,
408 );
409 }
410
411 loop {
412 if LOCKS_WATCH_KILL.load(std::sync::atomic::Ordering::SeqCst) {
413 break;
414 }
415 let snap = collect_locks_snapshot()?;
416 if json {
417 // Newline-delimited JSON: one frame = one line-terminated
418 // object. `to_string_pretty` emits embedded newlines; use
419 // the compact form under --watch so streaming consumers
420 // can parse per-line.
421 println!("{}", serde_json::to_string(&snap)?);
422 } else {
423 // ANSI clear-screen + home cursor, then the table.
424 // `\x1b[2J` clears; `\x1b[H` moves to (1,1). Both standard
425 // VT100 — every terminal ktstr supports honors them.
426 print!("\x1b[2J\x1b[H{}", render_locks_human(&snap));
427 }
428 std::thread::sleep(interval);
429 }
430
431 Ok(())
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 /// `LocksSnapshot` JSON top-level keys are stable: `llcs`,
439 /// `cpus`, `cache`, `run_dirs`. Downstream consumers of
440 /// `ktstr locks --json` (shell scripts piping through `jq`, the
441 /// mdbook recipe pages, future dashboards) parse against these
442 /// names — a refactor that renames them would silently break
443 /// every consumer.
444 ///
445 /// Also pins the `rename_all = "snake_case"` contract on the
446 /// nested row structs: LlcLockRow's "llc_idx" and "numa_node",
447 /// RunDirLockRow's "run_key", must NOT emit as camelCase.
448 #[test]
449 fn locks_snapshot_json_field_names_are_stable() {
450 let snap = LocksSnapshot {
451 llcs: vec![LlcLockRow {
452 llc_idx: 0,
453 numa_node: Some(1),
454 lockfile: "/tmp/ktstr-llc-0.lock".to_string(),
455 holders: Vec::new(),
456 }],
457 cpus: vec![CpuLockRow {
458 cpu: 3,
459 numa_node: None,
460 lockfile: "/tmp/ktstr-cpu-3.lock".to_string(),
461 holders: Vec::new(),
462 }],
463 cache: vec![CacheLockRow {
464 cache_key: "6.14.2-tarball-x86_64".to_string(),
465 lockfile: "/tmp/.locks/6.14.2-tarball-x86_64.lock".to_string(),
466 holders: Vec::new(),
467 }],
468 run_dirs: vec![RunDirLockRow {
469 run_key: "6.14-abc1234".to_string(),
470 lockfile: "/tmp/.locks/6.14-abc1234.lock".to_string(),
471 holders: Vec::new(),
472 }],
473 };
474 let val = serde_json::to_value(&snap).expect("serde serialize");
475 // Top-level keys.
476 assert!(
477 val.get("llcs").is_some(),
478 "top-level must have 'llcs': {val}"
479 );
480 assert!(
481 val.get("cpus").is_some(),
482 "top-level must have 'cpus': {val}"
483 );
484 assert!(
485 val.get("cache").is_some(),
486 "top-level must have 'cache': {val}"
487 );
488 assert!(
489 val.get("run_dirs").is_some(),
490 "top-level must have 'run_dirs': {val}"
491 );
492 // Nested LLC row.
493 let llc0 = &val["llcs"][0];
494 assert!(
495 llc0.get("llc_idx").is_some(),
496 "llc_idx (snake_case): {llc0}"
497 );
498 assert!(llc0.get("numa_node").is_some(), "numa_node: {llc0}");
499 assert!(llc0.get("lockfile").is_some(), "lockfile: {llc0}");
500 assert!(llc0.get("holders").is_some(), "holders: {llc0}");
501 // Nested CPU row.
502 let cpu0 = &val["cpus"][0];
503 assert!(cpu0.get("cpu").is_some());
504 assert!(cpu0.get("numa_node").is_some());
505 // Nested Cache row — cache_key stays snake_case.
506 let cache0 = &val["cache"][0];
507 assert!(cache0.get("cache_key").is_some(), "cache_key: {cache0}");
508 // Nested RunDir row — run_key stays snake_case.
509 let run0 = &val["run_dirs"][0];
510 assert!(run0.get("run_key").is_some(), "run_key: {run0}");
511 assert!(run0.get("lockfile").is_some(), "lockfile: {run0}");
512 assert!(run0.get("holders").is_some(), "holders: {run0}");
513 }
514
515 /// `collect_locks_snapshot_from` on a fresh tempdir with no
516 /// ktstr lockfiles returns an empty LocksSnapshot (all four
517 /// row vectors empty). Production wrapper always sees the same
518 /// behavior when `/tmp` has no `ktstr-*.lock` files, the cache
519 /// dir has no `.locks/` subdirectory, and the runs root has
520 /// no `.locks/` subdirectory.
521 #[test]
522 fn collect_locks_snapshot_empty_roots() {
523 use tempfile::TempDir;
524 let tmp_dir = TempDir::new().expect("tempdir tmp_root");
525 let cache_dir = TempDir::new().expect("tempdir cache_root");
526 let runs_dir = TempDir::new().expect("tempdir runs_root");
527 let snap = collect_locks_snapshot_from(
528 tmp_dir.path(),
529 Some(cache_dir.path()),
530 Some(runs_dir.path()),
531 )
532 .expect("collect must succeed on empty roots");
533 assert!(snap.llcs.is_empty(), "no ktstr-llc-*.lock → empty llcs");
534 assert!(snap.cpus.is_empty(), "no ktstr-cpu-*.lock → empty cpus");
535 assert!(snap.cache.is_empty(), "no .locks/ → empty cache");
536 assert!(
537 snap.run_dirs.is_empty(),
538 "no .locks/ under runs_root → empty run_dirs",
539 );
540 }
541
542 /// `collect_locks_snapshot_from` discovers synthetic LLC + CPU
543 /// lockfiles placed under the injected tmp_root. Also pins:
544 /// llc_idx / cpu parse from filename stem, sort ascending,
545 /// exclude files whose stem doesn't match the expected
546 /// `ktstr-{llc,cpu}-{N}` format.
547 #[test]
548 fn collect_locks_snapshot_discovers_lockfiles() {
549 use tempfile::TempDir;
550 let tmp_dir = TempDir::new().expect("tempdir");
551 let path = tmp_dir.path();
552 // Plant 2 LLC lockfiles (out of order — snapshot must sort
553 // ascending), 1 CPU lockfile, and a junk file that mustn't
554 // appear in the snapshot.
555 std::fs::write(path.join("ktstr-llc-5.lock"), b"").expect("plant llc-5");
556 std::fs::write(path.join("ktstr-llc-2.lock"), b"").expect("plant llc-2");
557 std::fs::write(path.join("ktstr-cpu-7.lock"), b"").expect("plant cpu-7");
558 // Junk: looks close but doesn't match the prefix-N-.lock
559 // pattern. The parse::<usize>() on "oops" fails → skip.
560 std::fs::write(path.join("ktstr-llc-oops.lock"), b"").expect("plant junk");
561 let snap = collect_locks_snapshot_from(path, None, None).expect("collect must succeed");
562 // LLC rows, ascending.
563 assert_eq!(snap.llcs.len(), 2);
564 assert_eq!(snap.llcs[0].llc_idx, 2, "sort ascending: llc 2 first");
565 assert_eq!(snap.llcs[1].llc_idx, 5, "sort ascending: llc 5 second");
566 // CPU row.
567 assert_eq!(snap.cpus.len(), 1);
568 assert_eq!(snap.cpus[0].cpu, 7);
569 // Cache row empty because cache_root=None.
570 assert!(snap.cache.is_empty());
571 // Run-dir row empty because runs_root=None.
572 assert!(snap.run_dirs.is_empty());
573 }
574
575 /// `collect_locks_snapshot_from` discovers synthetic per-run-key
576 /// lockfiles planted under `{runs_root}/.locks/*.lock`. Pins:
577 /// run_key parses as the file stem (the `{kernel}-{project_commit}`
578 /// dirname), rows sort ascending by run_key, and the cache /
579 /// run-dir scans live in independent code paths (passing
580 /// `cache_root=None` does NOT suppress run-dir enumeration).
581 #[test]
582 fn collect_locks_snapshot_discovers_run_dir_lockfiles() {
583 use tempfile::TempDir;
584 let runs_dir = TempDir::new().expect("tempdir runs_root");
585 let locks_dir = runs_dir.path().join(crate::flock::LOCK_DIR_NAME);
586 std::fs::create_dir_all(&locks_dir).expect("mkdir .locks/");
587 // Plant two run-dir lockfiles out of order — snapshot must
588 // sort ascending. Use realistic run-key shapes.
589 std::fs::write(locks_dir.join("7.0-def5678.lock"), b"").expect("plant 7.0");
590 std::fs::write(locks_dir.join("6.14-abc1234.lock"), b"").expect("plant 6.14");
591 // tmp_root tempdir is empty so the LLC/CPU scans yield no
592 // rows — keeps the assertion focused on the run_dirs path.
593 let tmp_dir = TempDir::new().expect("tempdir tmp_root");
594 let snap = collect_locks_snapshot_from(tmp_dir.path(), None, Some(runs_dir.path()))
595 .expect("collect must succeed");
596 assert_eq!(snap.run_dirs.len(), 2);
597 assert_eq!(
598 snap.run_dirs[0].run_key, "6.14-abc1234",
599 "sort ascending: 6.14 lexically before 7.0",
600 );
601 assert_eq!(snap.run_dirs[1].run_key, "7.0-def5678");
602 }
603
604 // ---------------------------------------------------------------
605 // render_locks_human — human table rendering
606 // ---------------------------------------------------------------
607 //
608 // The renderer composes four stacked sections (LLC, Per-CPU,
609 // Cache-entry, Run-dir) — each prefixed by a heading and
610 // either a comfy-table render or `(none)` when empty. The
611 // tests pin the heading strings, the `(none)` empty-section
612 // sentinel, the per-row holder formatting, and the `?`
613 // sentinel for missing NUMA nodes.
614
615 /// Empty snapshot renders all four headings with `(none)`
616 /// under each. Pins the section ordering: LLC → Per-CPU →
617 /// Cache-entry → Run-dir, in that order. A regression that
618 /// reordered the sections would silently change the operator's
619 /// scanning order.
620 #[test]
621 fn render_locks_human_empty_snapshot_emits_all_headings_with_none() {
622 let snap = LocksSnapshot {
623 llcs: Vec::new(),
624 cpus: Vec::new(),
625 cache: Vec::new(),
626 run_dirs: Vec::new(),
627 };
628 let out = render_locks_human(&snap);
629 // Headings appear in the canonical scan order.
630 let llc_pos = out.find("LLC locks:").expect("LLC heading");
631 let cpu_pos = out.find("Per-CPU locks:").expect("Per-CPU heading");
632 let cache_pos = out.find("Cache-entry locks:").expect("Cache heading");
633 let run_pos = out.find("Run-dir locks:").expect("Run-dir heading");
634 assert!(
635 llc_pos < cpu_pos && cpu_pos < cache_pos && cache_pos < run_pos,
636 "headings must appear in order LLC → Per-CPU → Cache → Run-dir; got: {out}",
637 );
638 // Each section's empty body must be the `(none)` sentinel
639 // — distinguishing "no locks of this kind" from a display
640 // bug that swallowed the table without a fallback render.
641 // We expect FOUR `(none)` lines, one per empty section.
642 let none_count = out.matches("(none)").count();
643 assert_eq!(
644 none_count, 4,
645 "all four empty sections must render `(none)`; got: {out}",
646 );
647 }
648
649 /// Populated LLC row carries: the LLC index, the NUMA node
650 /// (numeric when available), the lockfile path, and the
651 /// holder list (rendered as `pid (cmdline)` joined by `\n`).
652 /// Pins the per-row column formatting.
653 #[test]
654 fn render_locks_human_populated_llc_row_includes_pid_cmdline_and_node() {
655 let snap = LocksSnapshot {
656 llcs: vec![LlcLockRow {
657 llc_idx: 3,
658 numa_node: Some(1),
659 lockfile: "/tmp/ktstr-llc-3.lock".to_string(),
660 holders: vec![crate::flock::HolderInfo {
661 pid: 4321,
662 cmdline: "ktstr-test-binary".to_string(),
663 }],
664 }],
665 cpus: Vec::new(),
666 cache: Vec::new(),
667 run_dirs: Vec::new(),
668 };
669 let out = render_locks_human(&snap);
670 // LLC index appears as a row cell.
671 assert!(out.contains("3"), "LLC index must appear: {out}");
672 // NUMA node `1` appears (not `?` — sysfs probe succeeded
673 // here).
674 assert!(out.contains("1"), "NUMA node must appear: {out}");
675 // Lockfile path appears verbatim.
676 assert!(
677 out.contains("/tmp/ktstr-llc-3.lock"),
678 "lockfile path must appear: {out}",
679 );
680 // Holder render is `pid (cmdline)`.
681 assert!(out.contains("4321"), "holder pid must appear: {out}");
682 assert!(
683 out.contains("ktstr-test-binary"),
684 "holder cmdline must appear: {out}",
685 );
686 assert!(
687 out.contains("4321 (ktstr-test-binary)"),
688 "holder must render as `pid (cmdline)`: {out}",
689 );
690 }
691
692 /// Multi-holder LLC row joins holders with `\n` so each
693 /// holder lands on its own line within the comfy-table cell.
694 /// Pins the `\n` separator (a regression that re-introduced
695 /// the prior `, ` separator would surface as a wrap-mid-cmdline
696 /// regression on narrow terminals).
697 #[test]
698 fn render_locks_human_multi_holder_row_uses_newline_separator() {
699 let snap = LocksSnapshot {
700 llcs: vec![LlcLockRow {
701 llc_idx: 0,
702 numa_node: None,
703 lockfile: "/tmp/ktstr-llc-0.lock".to_string(),
704 holders: vec![
705 crate::flock::HolderInfo {
706 pid: 100,
707 cmdline: "first".to_string(),
708 },
709 crate::flock::HolderInfo {
710 pid: 200,
711 cmdline: "second".to_string(),
712 },
713 ],
714 }],
715 cpus: Vec::new(),
716 cache: Vec::new(),
717 run_dirs: Vec::new(),
718 };
719 let out = render_locks_human(&snap);
720 // Both holders appear in the rendered output.
721 assert!(out.contains("100 (first)"), "first holder: {out}");
722 assert!(out.contains("200 (second)"), "second holder: {out}");
723 }
724
725 /// Missing NUMA node (sysfs probe failed) renders as `?` in
726 /// the NODE column. Pins the sentinel — a regression that
727 /// emitted blank or `null` would lose the operator-visible
728 /// signal that the probe failed.
729 #[test]
730 fn render_locks_human_unknown_node_renders_question_mark() {
731 let snap = LocksSnapshot {
732 llcs: vec![LlcLockRow {
733 llc_idx: 7,
734 numa_node: None,
735 lockfile: "/tmp/ktstr-llc-7.lock".to_string(),
736 holders: Vec::new(),
737 }],
738 cpus: Vec::new(),
739 cache: Vec::new(),
740 run_dirs: Vec::new(),
741 };
742 let out = render_locks_human(&snap);
743 // The `?` sentinel must appear in the NUMA column.
744 assert!(
745 out.contains('?'),
746 "missing NUMA node must render as `?`: {out}",
747 );
748 }
749
750 /// Empty holder list renders the sentinel from
751 /// [`crate::flock::NO_HOLDERS_RECORDED`]. The sentinel value
752 /// itself lives next to the `read_holders` API as the
753 /// canonical "no holder data" tag — the renderer just splices
754 /// it into the table cell. Pins the renderer's delegation to
755 /// the shared sentinel rather than a local hard-coded string.
756 #[test]
757 fn render_locks_human_empty_holders_emits_no_holders_sentinel() {
758 let snap = LocksSnapshot {
759 llcs: Vec::new(),
760 cpus: vec![CpuLockRow {
761 cpu: 5,
762 numa_node: Some(0),
763 lockfile: "/tmp/ktstr-cpu-5.lock".to_string(),
764 holders: Vec::new(),
765 }],
766 cache: Vec::new(),
767 run_dirs: Vec::new(),
768 };
769 let out = render_locks_human(&snap);
770 assert!(
771 out.contains(crate::flock::NO_HOLDERS_RECORDED),
772 "empty holder list must render `{}`: got {out}",
773 crate::flock::NO_HOLDERS_RECORDED,
774 );
775 }
776
777 /// Cache-entry section uses `CACHE KEY` (not `CPU` / `LLC`)
778 /// as its header, distinguishing the section's row identity
779 /// from the LLC/CPU sections that share the same row shape.
780 /// Pins the per-section heading divergence.
781 #[test]
782 fn render_locks_human_cache_section_uses_cache_key_header() {
783 let snap = LocksSnapshot {
784 llcs: Vec::new(),
785 cpus: Vec::new(),
786 cache: vec![CacheLockRow {
787 cache_key: "6.14.2-tarball-x86_64".to_string(),
788 lockfile: "/tmp/.locks/6.14.2-tarball-x86_64.lock".to_string(),
789 holders: Vec::new(),
790 }],
791 run_dirs: Vec::new(),
792 };
793 let out = render_locks_human(&snap);
794 assert!(
795 out.contains("CACHE KEY"),
796 "cache-entry section must use `CACHE KEY` header: {out}",
797 );
798 assert!(
799 out.contains("6.14.2-tarball-x86_64"),
800 "cache key must appear in row: {out}",
801 );
802 }
803
804 /// Run-dir section uses `RUN KEY` as its header. Distinguishes
805 /// "this lock serializes sidecar writes" from the cache table's
806 /// "this lock serializes a kernel cache install" semantic.
807 #[test]
808 fn render_locks_human_run_dir_section_uses_run_key_header() {
809 let snap = LocksSnapshot {
810 llcs: Vec::new(),
811 cpus: Vec::new(),
812 cache: Vec::new(),
813 run_dirs: vec![RunDirLockRow {
814 run_key: "6.14-abc1234".to_string(),
815 lockfile: "/tmp/.locks/6.14-abc1234.lock".to_string(),
816 holders: Vec::new(),
817 }],
818 };
819 let out = render_locks_human(&snap);
820 assert!(
821 out.contains("RUN KEY"),
822 "run-dir section must use `RUN KEY` header: {out}",
823 );
824 assert!(
825 out.contains("6.14-abc1234"),
826 "run key must appear in row: {out}",
827 );
828 }
829
830 // ---------------------------------------------------------------
831 // SIGINT handler — `--watch` loop kill flag
832 // ---------------------------------------------------------------
833
834 /// `LOCKS_WATCH_KILL` must read `false` in the cleared state so
835 /// the `--watch` redraw loop runs at least one iteration before
836 /// a SIGINT can fire. A sibling test (`*_flips_kill_flag`) may
837 /// have left the flag set; reset it first so the assertion pins
838 /// the cleared-state contract the loop's `if KILL { break }`
839 /// gate (see [`list_locks`]) depends on, not whatever a prior
840 /// test happened to leave behind.
841 #[test]
842 fn locks_watch_kill_default_state_is_false() {
843 use std::sync::atomic::Ordering::SeqCst;
844 // Clear, then assert the cleared state reads false. Without
845 // the reset this would be coupled to sibling-test ordering;
846 // with it the test concretely pins "cleared ⇒ loop runs".
847 LOCKS_WATCH_KILL.store(false, SeqCst);
848 assert!(
849 !LOCKS_WATCH_KILL.load(SeqCst),
850 "cleared LOCKS_WATCH_KILL must read false so the watch \
851 loop runs at least one iteration before SIGINT",
852 );
853 }
854
855 /// Calling the SIGINT handler with a synthetic signal flips
856 /// the kill flag to `true`. The handler is `extern "C" fn
857 /// (libc::c_int)` and writes the atomic via
858 /// `Ordering::SeqCst`. Pins the handler's effect — a
859 /// regression that lost the store would leave the watch loop
860 /// running forever on Ctrl-C.
861 #[test]
862 fn locks_watch_sigint_handler_flips_kill_flag() {
863 // Reset the flag so a sibling test that already flipped
864 // it doesn't shadow the assertion. SeqCst store is
865 // sufficient ordering — the test is single-threaded.
866 LOCKS_WATCH_KILL.store(false, std::sync::atomic::Ordering::SeqCst);
867 // Invoke the handler with a synthetic SIGINT value. The
868 // handler ignores the signal arg (the `_sig` underscore-
869 // prefixed binding) so any int works.
870 super::locks_watch_sigint_handler(libc::SIGINT);
871 assert!(
872 LOCKS_WATCH_KILL.load(std::sync::atomic::Ordering::SeqCst),
873 "SIGINT handler must flip LOCKS_WATCH_KILL to true",
874 );
875 // Reset for sibling tests that may run after.
876 LOCKS_WATCH_KILL.store(false, std::sync::atomic::Ordering::SeqCst);
877 }
878
879 // ---------------------------------------------------------------
880 // list_locks one-shot path — collect_locks_snapshot + render
881 // ---------------------------------------------------------------
882
883 /// `list_locks(false, None)` (human, one-shot) on a fresh
884 /// process produces no panic. The function reads the host's
885 /// `/tmp/`, the cache root's `.locks/`, and the runs root's
886 /// `.locks/` — every miss falls through to an empty Vec, so
887 /// the worst case is "all empty" and the renderer emits four
888 /// `(none)` blocks. Pins the no-panic contract on a host that
889 /// genuinely has no ktstr locks active.
890 #[test]
891 fn list_locks_one_shot_no_panic_on_default_host() {
892 // The function prints to stdout — we redirect it to a
893 // buffer via `print!` only when stdout supports color, but
894 // the Read tool doesn't capture the print. The test pins
895 // "no panic, returns Ok" rather than the printed content,
896 // which is implicitly covered by `render_locks_human`'s
897 // dedicated tests.
898 //
899 // Use the seam `collect_locks_snapshot_from` with an
900 // isolated tempdir to avoid reading the host's actual
901 // `/tmp/` (which may legitimately contain ktstr locks
902 // during concurrent test runs).
903 use tempfile::TempDir;
904 let tmp_dir = TempDir::new().expect("tempdir");
905 let snap = collect_locks_snapshot_from(tmp_dir.path(), None, None)
906 .expect("collect on empty roots must succeed");
907 // Assert the snapshot is a structurally sound input to
908 // the renderer without invoking the renderer (the
909 // renderer's correctness is covered by the
910 // `render_locks_human_*` tests above).
911 assert!(snap.llcs.is_empty());
912 assert!(snap.cpus.is_empty());
913 assert!(snap.cache.is_empty());
914 assert!(snap.run_dirs.is_empty());
915 }
916}