ktstr/flock/holder.rs
1//! [`super::HolderInfo`] construction + rendering for diagnostics.
2//!
3//! Two roles:
4//!
5//! - [`holder_info_for_pid`] — read `/proc/{pid}/cmdline`, shape it
6//! for a [`super::HolderInfo`] (lossy UTF-8, `\0 → space`, truncate
7//! to [`CMDLINE_MAX_CHARS`] with `…` suffix). Missing /
8//! permission-denied / racing /proc entries — and an empty
9//! cmdline (kernel threads) — fall back to
10//! `"<cmdline unavailable>"` so the PID still surfaces.
11//! - [`format_holder_list`] — render a `&[HolderInfo]` for inclusion
12//! in an operator-facing error string. Empty list yields the
13//! [`NO_HOLDERS_RECORDED`] sentinel; non-empty renders one
14//! `pid={pid} cmd={cmdline}` line per holder, newline-separated
15//! and two-space-indented.
16
17use super::HolderInfo;
18
19/// Cmdline truncation limit: 100 chars, so a rendered holder line
20/// stays single-line. Referenced by `HolderInfo`'s doc in
21/// [`super::HolderInfo`].
22const CMDLINE_MAX_CHARS: usize = 100;
23
24/// Diagnostic text for lock-holder error messages when /proc/locks
25/// lists no PID against the lockfile inode. Centralized so every
26/// caller renders the empty-holders case with the same string.
27/// Non-empty so log-scrapers can key on it without accidentally
28/// matching a blank field.
29pub(crate) const NO_HOLDERS_RECORDED: &str = "<none recorded>";
30
31/// Read and shape `/proc/{pid}/cmdline` for a [`HolderInfo`].
32/// `\0` → ` `, lossy UTF-8, truncated to [`CMDLINE_MAX_CHARS`] with
33/// `…` suffix on overflow. Missing / racing / permission-denied on
34/// `/proc/{pid}/cmdline` — and a successfully-read but empty cmdline
35/// (kernel threads) — produce `"<cmdline unavailable>"`; the pid
36/// still carries diagnostic value even without the command.
37pub(super) fn holder_info_for_pid(pid: u32) -> HolderInfo {
38 let raw = match std::fs::read(format!("/proc/{pid}/cmdline")) {
39 Ok(bytes) => bytes,
40 Err(_) => {
41 return HolderInfo {
42 pid,
43 cmdline: "<cmdline unavailable>".to_string(),
44 };
45 }
46 };
47 // Kernel writes argv joined with \0 and terminated by \0. Lossy
48 // decode handles non-UTF-8 argv bytes (rare — most binaries use
49 // UTF-8 args, but the kernel does not enforce it).
50 let text: String = String::from_utf8_lossy(&raw)
51 .chars()
52 .map(|c| if c == '\0' { ' ' } else { c })
53 .collect::<String>()
54 .trim_end()
55 .to_string();
56 let truncated = if text.chars().count() > CMDLINE_MAX_CHARS {
57 let head: String = text.chars().take(CMDLINE_MAX_CHARS).collect();
58 format!("{head}…")
59 } else if text.is_empty() {
60 "<cmdline unavailable>".to_string()
61 } else {
62 text
63 };
64 HolderInfo {
65 pid,
66 cmdline: truncated,
67 }
68}
69
70/// Format a [`HolderInfo`] slice for inclusion in user-facing error
71/// strings. Empty slice yields the `NO_HOLDERS_RECORDED` sentinel so the
72/// diagnostic is unambiguous — a stale lockfile whose holder has
73/// exited presents as empty, and the error should say so rather than
74/// print a misleading blank. Non-empty renders one
75/// `pid={pid} cmd={cmdline}` line per holder, newline-separated and
76/// indented two spaces, so a multi-holder error stays readable when
77/// embedded in a wrapping anyhow chain; the prior comma-joined form
78/// ran every holder into a single wide line that terminals wrapped
79/// arbitrarily mid-cmdline.
80pub fn format_holder_list(holders: &[HolderInfo]) -> String {
81 if holders.is_empty() {
82 NO_HOLDERS_RECORDED.to_string()
83 } else {
84 holders
85 .iter()
86 .map(|h| format!(" pid={} cmd={}", h.pid, h.cmdline))
87 .collect::<Vec<_>>()
88 .join("\n")
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 // ---------------------------------------------------------------
97 // format_holder_list — rendering contract
98 // ---------------------------------------------------------------
99
100 /// Empty slice yields the sentinel [`NO_HOLDERS_RECORDED`] so
101 /// log-scrapers have a stable key.
102 #[test]
103 fn format_holder_list_empty_yields_sentinel() {
104 assert_eq!(format_holder_list(&[]), NO_HOLDERS_RECORDED);
105 }
106
107 /// Single holder renders with the ` pid={pid} cmd={cmdline}`
108 /// shape. Two-space indent is load-bearing — a future revert
109 /// to comma-join would break terminal rendering on
110 /// multi-holder lockfiles.
111 #[test]
112 fn format_holder_list_single_holder() {
113 let holders = [HolderInfo {
114 pid: 12345,
115 cmdline: "cargo build".to_string(),
116 }];
117 assert_eq!(format_holder_list(&holders), " pid=12345 cmd=cargo build");
118 }
119
120 /// Multiple holders newline-separated (not comma-joined). The
121 /// previous shape was `", "` — this test pins the newline.
122 #[test]
123 fn format_holder_list_multiple_newline_separated() {
124 let holders = [
125 HolderInfo {
126 pid: 1,
127 cmdline: "a".to_string(),
128 },
129 HolderInfo {
130 pid: 2,
131 cmdline: "b".to_string(),
132 },
133 ];
134 let out = format_holder_list(&holders);
135 assert!(out.contains("\n"), "must contain newline: {out}");
136 assert!(!out.contains(", "), "must NOT contain comma-space: {out}");
137 assert_eq!(out, " pid=1 cmd=a\n pid=2 cmd=b");
138 }
139
140 /// [`HolderInfo`] serializes with `pid` and `cmdline` as
141 /// snake_case keys — stable JSON contract for `ktstr locks
142 /// --json` downstream consumers. A future refactor that
143 /// rename_all = "camelCase" (or drops the derive) would
144 /// silently break shell-script consumers that `jq .[].pid`;
145 /// this test pins the key names so that regression fails the
146 /// build.
147 #[test]
148 fn holder_info_json_keys_are_snake_case() {
149 let holder = HolderInfo {
150 pid: 123,
151 cmdline: "bash".to_string(),
152 };
153 let val = serde_json::to_value(&holder).expect("serialize");
154 // Pin both keys exist + have expected types.
155 assert_eq!(val["pid"], serde_json::json!(123));
156 assert_eq!(val["cmdline"], serde_json::json!("bash"));
157 // Negative: no camelCase variants slipped in.
158 assert!(
159 val.get("cmdLine").is_none(),
160 "camelCase cmdLine must not appear: {val}",
161 );
162 }
163}