#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProcessIdentity {
pub pid: u32,
pub ppid: u32,
pub start_key: String,
pub argv: Vec<String>,
}
pub fn process_identity(pid: u32) -> Option<ProcessIdentity> {
platform::identity_impl(pid)
}
pub fn argv_has_prefix(argv: &[String], prefix: &[&str]) -> bool {
argv.len() >= prefix.len() && prefix.iter().enumerate().all(|(i, want)| &argv[i] == want)
}
pub fn argv0_basename(argv: &[String]) -> Option<&str> {
let argv0 = argv.first()?;
let base = argv0.rsplit(['/', '\\']).next().unwrap_or(argv0);
Some(base.strip_suffix(".exe").unwrap_or(base))
}
const PIDFILE_OWNER_BASENAME_PREFIX: &str = "omniterm";
pub fn pidfile_pid_is_omniterm(ident: Option<&ProcessIdentity>) -> bool {
ident
.and_then(|id| argv0_basename(&id.argv))
.is_some_and(|base| base.starts_with(PIDFILE_OWNER_BASENAME_PREFIX))
}
#[cfg(unix)]
pub fn pid_alive(pid: u32) -> bool {
let r = unsafe { libc::kill(pid as i32, 0) };
r == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
#[cfg(not(unix))]
pub fn pid_alive(pid: u32) -> bool {
platform::identity_impl(pid).is_some()
}
#[cfg(unix)]
pub fn kill_pid(pid: u32, sig: i32) -> std::io::Result<()> {
#[cfg(target_os = "linux")]
{
let pidfd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid as libc::pid_t, 0i32) };
if pidfd < 0 {
return kill_signal(pid, sig);
}
let rc = unsafe {
libc::syscall(
libc::SYS_pidfd_send_signal,
pidfd as libc::c_int,
sig,
std::ptr::null::<libc::siginfo_t>(),
0usize,
)
};
let err = std::io::Error::last_os_error();
unsafe { libc::close(pidfd as libc::c_int) };
if rc < 0 { Err(err) } else { Ok(()) }
}
#[cfg(not(target_os = "linux"))]
{
kill_signal(pid, sig)
}
}
#[cfg(unix)]
fn kill_signal(pid: u32, sig: i32) -> std::io::Result<()> {
if unsafe { libc::kill(pid as i32, sig) } == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
#[cfg(not(unix))]
pub fn kill_pid(_pid: u32, _sig: i32) -> std::io::Result<()> {
Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "kill_pid: Windows 无对应回退"))
}
pub fn kill_pid_forced(pid: u32) -> std::io::Result<()> {
#[cfg(unix)]
{
kill_pid(pid, libc::SIGKILL)
}
#[cfg(not(unix))]
{
kill_pid(pid, 9)
}
}
fn parse_stat_identity(stat: &str) -> Option<(u32, String)> {
let after_comm = stat.rsplit_once(')')?.1;
let fields: Vec<&str> = after_comm.split_whitespace().collect();
let ppid = fields.get(1)?.parse::<u32>().ok()?;
let starttime = (*fields.get(19)?).to_string();
Some((ppid, starttime))
}
fn parse_cmdline_argv(raw: &[u8]) -> Vec<String> {
raw.split(|&b| b == 0)
.filter(|part| !part.is_empty())
.map(|part| String::from_utf8_lossy(part).into_owned())
.collect()
}
#[cfg(target_os = "linux")]
mod platform {
use super::*;
pub fn identity_impl(pid: u32) -> Option<ProcessIdentity> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let (ppid, start_key) = parse_stat_identity(&stat)?;
let raw = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
let argv = parse_cmdline_argv(&raw);
Some(ProcessIdentity { pid, ppid, start_key, argv })
}
}
#[cfg(all(unix, not(target_os = "linux")))]
fn parse_ps_identity(line: &str, pid: u32) -> Option<ProcessIdentity> {
let mut it = line.split_whitespace();
let got_pid: u32 = it.next()?.parse().ok()?;
if got_pid != pid {
return None;
}
let ppid = it.next()?.parse::<u32>().ok()?;
let start_key: Vec<&str> = (0..5).map_while(|_| it.next()).collect();
if start_key.len() < 5 {
return None;
}
let argv: Vec<String> = it.map(str::to_string).collect();
Some(ProcessIdentity { pid, ppid, start_key: start_key.join(" "), argv })
}
#[cfg(all(unix, not(target_os = "linux")))]
mod platform {
use super::*;
pub fn identity_impl(pid: u32) -> Option<ProcessIdentity> {
let out = std::process::Command::new("ps")
.args(["-o", "pid=,ppid=,lstart=,args=", "-p", &pid.to_string()])
.output()
.ok()?;
if !out.status.success() {
return None;
}
parse_ps_identity(&String::from_utf8_lossy(&out.stdout), pid)
}
}
#[cfg(windows)]
mod platform {
use super::*;
pub fn identity_impl(pid: u32) -> Option<ProcessIdentity> {
use sysinfo::{Pid, System};
let mut sys = System::new();
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
let process = sys.process(Pid::from_u32(pid))?;
Some(ProcessIdentity {
pid,
ppid: process.parent().map(|pp| pp.as_u32()).unwrap_or(0),
start_key: process.start_time().to_string(),
argv: process.cmd().iter().map(|s| s.to_string_lossy().into_owned()).collect(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_stat_handles_comm_with_parens_and_spaces() {
let stat = "42 (tmux: server) S 7 7 7 0 -1 4194304 1 2 3 4 5 6 7 8 20 0 1 999 \
123456 64000000 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0";
let (ppid, start_key) = parse_stat_identity(stat).expect("should parse");
assert_eq!(ppid, 7, "ppid 应取 ')' 后第 2 个字段");
assert_eq!(start_key, "123456", "starttime 应取 ')' 后第 20 个字段");
}
#[test]
fn parse_stat_rejects_malformed() {
assert!(parse_stat_identity("no parens here").is_none());
assert!(parse_stat_identity("1 (x) S").is_none(), "字段不足应拒绝");
}
#[test]
fn parse_cmdline_splits_on_nul_and_drops_empty() {
assert_eq!(
parse_cmdline_argv(b"tmux\0-C\0attach-session\0"),
["tmux", "-C", "attach-session"]
);
assert!(parse_cmdline_argv(b"").is_empty(), "僵尸/内核线程 cmdline 为空");
}
#[test]
fn argv_prefix_is_structural_not_substring() {
let argv: Vec<String> =
["tmux", "-C", "attach-session", "-t", "x"].iter().map(|s| s.to_string()).collect();
assert!(argv_has_prefix(&argv, &["tmux", "-C"]));
assert!(argv_has_prefix(&argv, &["tmux"]));
assert!(
!argv_has_prefix(&argv, &["tmux", "-C", "attach-session", "-t", "y"]),
"尾元素不等"
);
let vim: Vec<String> = ["vim", "tmux -C.md"].iter().map(|s| s.to_string()).collect();
assert!(!argv_has_prefix(&vim, &["tmux", "-C"]));
let echo: Vec<String> = ["echo", "tmux", "-C"].iter().map(|s| s.to_string()).collect();
assert!(!argv_has_prefix(&echo, &["tmux", "-C"]), "argv[0] 不等,不得命中");
}
#[test]
fn argv0_basename_strips_dirs_and_exe() {
let p = |s: &str| vec![s.to_string()];
assert_eq!(argv0_basename(&p("/usr/bin/omniterm")), Some("omniterm"));
assert_eq!(argv0_basename(&p("C:\\bin\\omniterm.exe")), Some("omniterm"));
assert_eq!(argv0_basename(&p("omniterm")), Some("omniterm"));
assert_eq!(argv0_basename(&[]), None);
}
fn ident_with_argv(argv: &[&str]) -> ProcessIdentity {
ProcessIdentity {
pid: 4242,
ppid: 1,
start_key: "start-key".to_string(),
argv: argv.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn pidfile_predicate_accepts_omniterm_family() {
assert!(pidfile_pid_is_omniterm(Some(&ident_with_argv(&["omniterm"]))));
assert!(
pidfile_pid_is_omniterm(Some(&ident_with_argv(&["omniterm.exe", "stop"]))),
"Windows .exe 后缀剥除后仍命中"
);
assert!(
pidfile_pid_is_omniterm(Some(&ident_with_argv(&["/usr/bin/omniterm", "-p", "9075"]))),
"绝对路径按基名命中"
);
assert!(
pidfile_pid_is_omniterm(Some(&ident_with_argv(&["omniterm-dev", "start"]))),
"worktree 变体基名前缀命中"
);
}
#[test]
fn pidfile_predicate_rejects_others_and_unreadable() {
assert!(
!pidfile_pid_is_omniterm(Some(&ident_with_argv(&["vim", "omniterm.md"]))),
"子串陷阱:vim 打开 omniterm.md 不得命中(结构化基名判断)"
);
assert!(!pidfile_pid_is_omniterm(Some(&ident_with_argv(&["tmux", "new"]))));
assert!(!pidfile_pid_is_omniterm(Some(&ident_with_argv(&[]))), "空 argv 不通过");
assert!(!pidfile_pid_is_omniterm(None), "身份读不到(进程已消亡)不通过");
}
#[test]
fn identity_of_self_is_readable() {
let me = process_identity(std::process::id()).expect("自身身份必须可读");
assert_eq!(me.pid, std::process::id());
assert!(me.ppid > 0);
assert!(!me.start_key.is_empty());
assert!(!me.argv.is_empty());
assert!(pid_alive(std::process::id()));
assert!(!pid_alive(0x7fff_ffff), "哨兵 pid 不应存活");
}
#[cfg(all(unix, not(target_os = "linux")))]
#[test]
fn parse_ps_identity_splits_lstart_fields() {
let line = " 42 7 Mon Aug 11 09:15:22 2026 tmux -C attach-session -t x\n";
let ident = parse_ps_identity(line, 42).expect("should parse");
assert_eq!(ident.ppid, 7);
assert_eq!(ident.start_key, "Mon Aug 11 09:15:22 2026");
assert_eq!(ident.argv, ["tmux", "-C", "attach-session", "-t", "x"]);
}
}