#[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
}
}
}
fn platform_pid_alive(pid: u32) -> std::io::Result<bool> {
#[cfg(unix)]
{
match std::process::Command::new("kill")
.arg("-0")
.arg(pid.to_string())
.output()
{
Ok(o) => Ok(parse_unix_kill_output(o.status.success(), &o.stderr)),
Err(error) => Err(error),
}
}
#[cfg(windows)]
{
let out = std::process::Command::new("tasklist")
.quiet()
.args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"])
.output();
match out {
Ok(o) => tasklist_result(
pid,
o.status.success(),
&o.stdout,
&o.stderr,
&o.status.to_string(),
),
Err(error) => Err(error),
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
Ok(true)
}
}
#[cfg(any(unix, test))]
fn parse_unix_kill_output(success: bool, stderr: &[u8]) -> bool {
if success {
return true;
}
!String::from_utf8_lossy(stderr)
.to_lowercase()
.contains("no such process")
}
#[cfg(any(windows, test))]
fn parse_windows_tasklist_output(pid: u32, stdout: &[u8]) -> std::io::Result<bool> {
if stdout.iter().all(u8::is_ascii_whitespace) {
return Err(std::io::Error::other("tasklist produced no output"));
}
let expected = pid.to_string();
let rows = String::from_utf8_lossy(stdout)
.lines()
.map(tasklist_csv_fields)
.collect::<Option<Vec<_>>>()
.ok_or_else(|| std::io::Error::other("could not parse tasklist CSV output"))?;
Ok(rows
.into_iter()
.any(|fields| fields.get(1).is_some_and(|field| field == &expected)))
}
#[cfg(any(windows, test))]
fn tasklist_csv_fields(line: &str) -> Option<Vec<String>> {
let mut fields = Vec::new();
let mut field = String::new();
let mut quoted = false;
let mut chars = line.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'"' if quoted && chars.peek() == Some(&'"') => {
field.push('"');
chars.next();
}
'"' => quoted = !quoted,
',' if !quoted => fields.push(std::mem::take(&mut field)),
_ => field.push(ch),
}
}
(!quoted).then(|| {
fields.push(field);
fields
})
}
#[cfg(any(windows, test))]
fn tasklist_result(
pid: u32,
success: bool,
stdout: &[u8],
stderr: &[u8],
status: &str,
) -> std::io::Result<bool> {
if success {
parse_windows_tasklist_output(pid, stdout)
} else {
Err(std::io::Error::other(format!(
"tasklist exited {status}: {}",
String::from_utf8_lossy(stderr).trim()
)))
}
}
#[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 src_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).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 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 unix_kill_output_only_marks_no_such_process_as_dead() {
assert!(parse_unix_kill_output(true, b""));
assert!(!parse_unix_kill_output(
false,
b"kill: (12345) - No such process\n"
));
assert!(parse_unix_kill_output(
false,
b"kill: (12345) - Operation not permitted\n"
));
}
#[test]
fn windows_tasklist_csv_parsing_handles_match_no_match_and_error() {
let pid = 12345;
assert!(
parse_windows_tasklist_output(
pid,
b"\"magi.exe\",\"12345\",\"Console\",\"1\",\"10 K\"\r\n"
)
.expect("整形式 CSV の一致行は生存を示す")
);
assert!(
parse_windows_tasklist_output(
pid,
b"\"magi,worker.exe\",\"12345\",\"Console\",\"1\",\"10 K\"\r\n"
)
.expect("カンマ入りイメージ名でも PID 列を読む")
);
assert!(
!parse_windows_tasklist_output(
pid,
b"INFO: No tasks are running which match the specified criteria.\r\n"
)
.expect("tasklist の no-match 出力は整形式である")
);
assert!(
parse_windows_tasklist_output(pid, b"\"magi.exe\",\"12345").is_err(),
"壊れた CSV は死亡ではなく利用不能である"
);
assert!(
parse_windows_tasklist_output(pid, b"").is_err(),
"空出力は死亡ではなく利用不能である"
);
assert!(
parse_windows_tasklist_output(pid, b"\r\n").is_err(),
"空白だけの出力は死亡ではなく利用不能である"
);
assert!(
tasklist_result(
pid,
true,
b"\"magi.exe\",\"12345\",\"Console\",\"1\",\"10 K\"\r\n",
b"",
"exit status: 0",
)
.expect("CSV の一致行は生存を示す")
);
let error = tasklist_result(pid, false, b"", b"Access is denied.\r\n", "exit status: 1")
.expect_err("tasklist の失敗は死亡ではなく利用不能である");
assert!(error.to_string().contains("Access is denied."));
}
#[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}")
}
}
}
}