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}