#[cfg(windows)]
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
pub trait Quiet {
fn quiet(&mut self) -> &mut Self;
}
impl Quiet for std::process::Command {
fn quiet(&mut self) -> &mut Self {
#[cfg(windows)]
{
use std::os::windows::process::CommandExt as _;
self.creation_flags(CREATE_NO_WINDOW);
}
self
}
}
impl Quiet for tokio::process::Command {
fn quiet(&mut self) -> &mut Self {
#[cfg(windows)]
{
self.creation_flags(CREATE_NO_WINDOW);
}
self
}
}
#[must_use]
pub fn pid_alive(pid: u32) -> bool {
pid_alive_with(pid, platform_pid_alive)
}
fn pid_alive_with<F>(pid: u32, query: F) -> bool
where
F: FnOnce(u32) -> std::io::Result<bool>,
{
match query(pid) {
Ok(alive) => alive,
Err(error) => {
static REPORTED: std::sync::Once = std::sync::Once::new();
REPORTED.call_once(|| {
tracing::warn!(
%pid,
%error,
"process liveness query unavailable; keeping locks rather than treating processes as dead"
);
});
true
}
}
}
#[must_use]
pub fn pid_status(pid: u32) -> Option<bool> {
pid_status_with(pid, platform_pid_alive)
}
fn pid_status_with<F>(pid: u32, query: F) -> Option<bool>
where
F: FnOnce(u32) -> std::io::Result<bool>,
{
query(pid).ok()
}
#[must_use]
pub fn process_started_at(pid: u32) -> Option<String> {
platform_process_started_at(pid).ok()
}
pub struct ProcProbe<S, I> {
status: S,
identity: I,
alive: std::collections::HashMap<u32, Option<bool>>,
started: std::collections::HashMap<u32, Option<String>>,
}
impl ProcProbe<fn(u32) -> Option<bool>, fn(u32) -> Option<String>> {
#[must_use]
pub fn real() -> Self {
Self::new(pid_status, process_started_at)
}
}
impl<S, I> ProcProbe<S, I>
where
S: FnMut(u32) -> Option<bool>,
I: FnMut(u32) -> Option<String>,
{
#[must_use]
pub fn new(status: S, identity: I) -> Self {
Self {
status,
identity,
alive: std::collections::HashMap::new(),
started: std::collections::HashMap::new(),
}
}
pub fn status(&mut self, pid: u32) -> Option<bool> {
*self.alive.entry(pid).or_insert_with(|| (self.status)(pid))
}
pub fn started_at(&mut self, pid: u32) -> Option<String> {
self.started
.entry(pid)
.or_insert_with(|| (self.identity)(pid))
.clone()
}
}
fn query_process(pid: u32) -> std::io::Result<Option<u64>> {
use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
if !sysinfo::IS_SUPPORTED_SYSTEM {
return Err(std::io::Error::other(
"process queries are unavailable on this platform",
));
}
#[cfg(target_os = "linux")]
if !linux_boot_time_readable() {
return Err(std::io::Error::other(
"boot time is unreadable: start times would not be stable",
));
}
let pid = Pid::from_u32(pid);
let own = Pid::from_u32(std::process::id());
let mut system = System::new();
system.refresh_processes_specifics(
ProcessesToUpdate::Some(&if pid == own {
vec![own]
} else {
vec![pid, own]
}),
true,
ProcessRefreshKind::nothing(),
);
if system.process(own).is_none_or(|p| p.start_time() == 0) {
return Err(std::io::Error::other(
"process table is unreadable: this process is not listed",
));
}
let found = system.process(pid).map(sysinfo::Process::start_time);
#[cfg(target_os = "linux")]
if found.is_none() && std::path::Path::new(&format!("/proc/{}", pid.as_u32())).exists() {
return Err(std::io::Error::other(
"process exists but its entry could not be read",
));
}
#[cfg(all(unix, not(target_os = "linux")))]
if found.is_none() && unix_pid_exists(pid.as_u32()) {
return Err(std::io::Error::other(
"process exists but its entry could not be read",
));
}
#[cfg(target_os = "linux")]
if found.is_none() && !linux_proc_shows_all_pids() {
return Err(std::io::Error::other(
"absence is unprovable: /proc may hide other users' processes",
));
}
Ok(found)
}
#[cfg(all(unix, not(target_os = "linux")))]
fn unix_pid_exists(pid: u32) -> bool {
let Ok(pid) = libc::pid_t::try_from(pid) else {
return false;
};
let rc = unsafe { libc::kill(pid, 0) };
rc == 0 || std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
}
#[cfg(target_os = "linux")]
fn linux_boot_time_readable() -> bool {
std::fs::read_to_string("/proc/stat").is_ok_and(|s| stat_has_btime(&s))
}
#[cfg(any(target_os = "linux", test))]
fn stat_has_btime(stat: &str) -> bool {
stat.lines().any(|l| {
l.strip_prefix("btime ")
.and_then(|v| v.trim().parse::<u64>().ok())
.is_some_and(|v| v > 0)
})
}
#[cfg(target_os = "linux")]
fn linux_proc_shows_all_pids() -> bool {
std::fs::read_to_string("/proc/self/mountinfo").is_ok_and(|s| mountinfo_proc_unhidden(&s))
}
#[cfg(any(target_os = "linux", test))]
fn mountinfo_proc_unhidden(mountinfo: &str) -> bool {
mountinfo
.lines()
.rfind(|l| l.split_whitespace().nth(4) == Some("/proc"))
.is_some_and(|l| {
l.split(|c: char| c.is_whitespace() || c == ',')
.filter_map(|o| o.strip_prefix("hidepid="))
.all(|v| matches!(v, "0" | "off"))
})
}
#[must_use]
pub fn is_identity_marker(s: &str) -> bool {
!s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
}
fn platform_process_started_at(pid: u32) -> std::io::Result<String> {
match query_process(pid)? {
Some(0) => Err(std::io::Error::other("process start time is unavailable")),
Some(started) => Ok(started.to_string()),
None => Err(std::io::Error::other("no such process")),
}
}
fn platform_pid_alive(pid: u32) -> std::io::Result<bool> {
query_process(pid).map(|found| found.is_some())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn the_flag_hides_a_console_rather_than_removing_or_creating_one() {
assert_eq!(CREATE_NO_WINDOW, 0x0800_0000);
assert_ne!(CREATE_NO_WINDOW, 0x0000_0008, "DETACHED_PROCESS");
assert_ne!(CREATE_NO_WINDOW, 0x0000_0010, "CREATE_NEW_CONSOLE");
}
#[test]
fn quiet_leaves_the_command_it_was_handed_intact() {
let mut cmd = tokio::process::Command::new("git");
cmd.args(["status", "--short"]).quiet();
let built = cmd.as_std();
assert_eq!(built.get_program(), "git");
let args: Vec<_> = built.get_args().collect();
assert_eq!(args, ["status", "--short"]);
}
#[test]
fn every_spawn_in_the_crate_is_quiet_or_documented_as_exempt() {
let manifest = std::env::var_os("CARGO_MANIFEST_DIR")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")));
let src_dir = manifest.join("src");
let mut offenders = Vec::new();
for entry in std::fs::read_dir(&src_dir).expect("read src dir") {
let path = entry.expect("dir entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_owned();
if file_name == "tui.rs" {
continue;
}
let text = std::fs::read_to_string(&path).expect("read source file");
let lines: Vec<&str> = text.lines().collect();
let spawn_at: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| l.contains("Command::new("))
.map(|(i, _)| i)
.collect();
for (pos, &start) in spawn_at.iter().enumerate() {
let end = spawn_at.get(pos + 1).copied().unwrap_or(lines.len());
let block = lines[start..end].join("\n");
if block.contains(".quiet()") {
continue;
}
if block.contains("DETACHED_PROCESS") {
continue;
}
let preceding = lines[start.saturating_sub(5)..start].join("\n");
if preceding.contains("#[cfg(unix)]") {
continue;
}
offenders.push(format!("{file_name}:{}", start + 1));
}
}
assert!(
offenders.is_empty(),
"Command::new without .quiet() and no documented exemption: {offenders:?}"
);
}
#[test]
fn btime_and_hidepid_parsers_distrust_what_they_cannot_confirm() {
assert!(stat_has_btime("cpu 1 2\nbtime 1700000000\n"));
assert!(!stat_has_btime("cpu 1 2\n"));
assert!(!stat_has_btime("btime 0\n"));
let open = "25 1 0:5 / /proc rw,nosuid - proc proc rw";
let hidden = "25 1 0:5 / /proc rw,nosuid - proc proc rw,hidepid=2";
assert!(mountinfo_proc_unhidden(open));
assert!(!mountinfo_proc_unhidden(hidden));
for v in ["1", "4", "ptraceable", "noaccess", "future"] {
let line = format!("25 1 0:5 / /proc rw - proc proc rw,hidepid={v}");
assert!(!mountinfo_proc_unhidden(&line), "hidepid={v}");
}
assert!(mountinfo_proc_unhidden(
"25 1 0:5 / /proc rw - proc proc rw,hidepid=0"
));
assert!(!mountinfo_proc_unhidden(""));
}
#[test]
fn pid_liveness_policy_is_deterministic_without_an_os_process_query() {
assert!(pid_alive_with(42, |_| Ok(true)));
assert!(!pid_alive_with(42, |_| Ok(false)));
}
#[test]
fn an_unavailable_process_query_is_never_mistaken_for_a_dead_process() {
assert!(pid_alive_with(42, |_| Err(std::io::Error::other(
"access denied"
))));
}
#[test]
fn pid_status_reports_alive_dead_and_unknown_as_three_distinct_answers() {
assert_eq!(pid_status_with(42, |_| Ok(true)), Some(true));
assert_eq!(pid_status_with(42, |_| Ok(false)), Some(false));
assert_eq!(
pid_status_with(42, |_| Err(std::io::Error::other("access denied"))),
None
);
}
#[test]
fn platform_query_reports_this_running_process_as_alive_or_unavailable() {
let pid = std::process::id();
match platform_pid_alive(pid) {
Ok(true) => {}
Ok(false) => {
panic!("OS の PID 問い合わせが実行中のテストプロセス {pid} を dead と報告した")
}
Err(error) if std::env::var_os("CI").is_some() => {
panic!("CI で OS の PID 問い合わせを実行できない(テストプロセス {pid}): {error}")
}
Err(error) => {
eprintln!("OS の PID 問い合わせは利用できません(テストプロセス {pid}): {error}")
}
}
}
#[test]
fn platform_query_reports_this_running_process_start_time_consistently_or_unavailable() {
let pid = std::process::id();
match (
platform_process_started_at(pid),
platform_process_started_at(pid),
) {
(Ok(first), Ok(second)) => {
assert_eq!(
first, second,
"同一の生存プロセスへの二回の問い合わせが食い違った"
);
assert!(is_identity_marker(&first), "整数文字列でない: {first}");
}
(Err(error), _) | (_, Err(error)) if std::env::var_os("CI").is_some() => {
panic!("CI で起動時刻の問い合わせを実行できない(テストプロセス {pid}): {error}")
}
_ => eprintln!("起動時刻の問い合わせは利用できません(テストプロセス {pid})"),
}
}
}