use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use crate::process_identity::{ProcessIdentity, argv_has_prefix, process_identity};
pub const MAX_TRACKED_CLIENTS: usize = 256;
pub const MAX_SESSION_BYTES: usize = 255;
pub const ORPHAN_FILE_MIN_AGE_SECS: u64 = 300;
pub const TMUX_CONTROL_ARGV_PREFIX: &[&str] = &["tmux", "-C"];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientEntry {
pub pid: u32,
pub spawn_ppid: u32,
pub start_key: String,
pub session: String,
}
impl ClientEntry {
pub fn new(pid: u32, spawn_ppid: u32, start_key: String, session: &str) -> Self {
Self { pid, spawn_ppid, start_key, session: truncate_utf8(session, MAX_SESSION_BYTES) }
}
}
fn truncate_utf8(s: &str, max_bytes: usize) -> String {
if s.len() <= max_bytes {
return s.to_string();
}
let mut end = max_bytes;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
s[..end].to_string()
}
#[derive(Debug, Serialize, Deserialize)]
struct RegistryFile {
v: u32,
born_at: u64,
entries: Vec<ClientEntry>,
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RegisterOutcome {
Registered,
RefusedFull,
}
#[derive(Clone)]
pub struct ClientRegistry {
inner: Arc<Inner>,
}
struct Inner {
path: PathBuf,
entries: Mutex<Vec<ClientEntry>>,
born_at: u64,
}
static GLOBAL: OnceLock<ClientRegistry> = OnceLock::new();
pub fn init_global(instance_stem: &str, instance_pid: u32) -> ClientRegistry {
let path = registry_dir().join(format!("{instance_stem}-{instance_pid}.clients"));
let registry = ClientRegistry::open(path);
let _ = GLOBAL.set(registry.clone());
registry
}
pub fn global() -> Option<ClientRegistry> {
GLOBAL.get().cloned()
}
fn registry_dir() -> PathBuf {
crate::omniterm_data_dir()
}
pub fn reconcile_all() -> ReconcileReport {
reconcile_dir(®istry_dir())
}
impl ClientRegistry {
pub fn open(path: PathBuf) -> Self {
let born_at = load_file(&path).map(|f| f.born_at).unwrap_or_else(now_secs);
Self { inner: Arc::new(Inner { path, entries: Mutex::new(Vec::new()), born_at }) }
}
#[allow(dead_code)] pub fn path(&self) -> &Path {
&self.inner.path
}
pub fn register(&self, entry: ClientEntry) -> RegisterOutcome {
let mut entries = self.inner.entries.lock().expect("登记表锁中毒");
entries.retain(is_live_entry);
if entries.len() >= MAX_TRACKED_CLIENTS {
warn!(
tracked = entries.len(),
"tmux 控制客户端登记表已满(MAX_TRACKED_CLIENTS={}),拒绝登记 pid={};\
父死兜底由 PDEATHSIG 承担,登记缺失不构成泄漏",
MAX_TRACKED_CLIENTS,
entry.pid
);
return RegisterOutcome::RefusedFull;
}
entries.retain(|e| e.pid != entry.pid);
entries.push(entry);
self.save_locked(&entries);
RegisterOutcome::Registered
}
pub fn deregister(&self, pid: u32) {
let mut entries = self.inner.entries.lock().expect("登记表锁中毒");
let before = entries.len();
entries.retain(|e| e.pid != pid);
if entries.len() != before {
self.save_locked(&entries);
}
}
pub fn entries(&self) -> Vec<ClientEntry> {
self.inner.entries.lock().expect("登记表锁中毒").clone()
}
pub fn remove_file(&self) {
let _ = std::fs::remove_file(&self.inner.path);
}
fn save_locked(&self, entries: &[ClientEntry]) {
if let Err(e) = save_file(&self.inner.path, self.inner.born_at, entries) {
warn!(path = %self.inner.path.display(), error = %e, "写 tmux 控制客户端登记文件失败");
}
}
}
fn is_live_entry(entry: &ClientEntry) -> bool {
process_identity(entry.pid).is_some()
}
pub fn is_orphaned_tracked_client(ident: &ProcessIdentity, entry: &ClientEntry) -> bool {
argv_has_prefix(&ident.argv, TMUX_CONTROL_ARGV_PREFIX)
&& ident.ppid != entry.spawn_ppid
&& ident.start_key == entry.start_key
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct ReconcileReport {
pub killed: u32,
pub cleared: u32,
pub skipped_reuse: u32,
pub skipped_mismatch: u32,
pub kept: u32,
pub files_removed: u32,
}
fn save_file(path: &Path, born_at: u64, entries: &[ClientEntry]) -> std::io::Result<()> {
let file = RegistryFile { v: 1, born_at, entries: entries.to_vec() };
let json = serde_json::to_vec_pretty(&file)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let tmp = path.with_extension("clients.tmp");
std::fs::write(&tmp, &json)?;
std::fs::rename(&tmp, path)
}
fn load_file(path: &Path) -> Option<RegistryFile> {
let raw = std::fs::read(path).ok()?;
serde_json::from_slice(&raw).ok()
}
pub fn reconcile_dir(dir: &Path) -> ReconcileReport {
let mut report = ReconcileReport::default();
let Ok(read_dir) = std::fs::read_dir(dir) else {
return report;
};
for dirent in read_dir.flatten() {
let path = dirent.path();
if path.extension().is_none_or(|ext| ext != "clients") {
continue;
}
reconcile_file(&path, &mut report);
}
report
}
fn reconcile_file(path: &Path, report: &mut ReconcileReport) {
let Some(file) = load_file(path) else {
warn!(path = %path.display(), "tmux 控制客户端登记文件不可解析,跳过对账");
return;
};
for entry in &file.entries {
match process_identity(entry.pid) {
None => {
report.cleared += 1; }
Some(ident) if ident.start_key != entry.start_key => {
warn!(
pid = entry.pid,
"登记的 tmux 控制客户端 pid 已被复用(start_key 不符),跳过击杀"
);
report.skipped_reuse += 1;
}
Some(ident) if !argv_has_prefix(&ident.argv, TMUX_CONTROL_ARGV_PREFIX) => {
warn!(pid = entry.pid, argv = ?ident.argv, "登记 pid 的 argv 不是 tmux -C,跳过击杀");
report.skipped_mismatch += 1;
}
Some(ident) if ident.ppid == entry.spawn_ppid => {
report.kept += 1; }
Some(ident) if is_orphaned_tracked_client(&ident, entry) => {
info!(
pid = entry.pid,
session = %entry.session,
spawn_ppid = entry.spawn_ppid,
"启动对账:击杀残留 tmux -C 孤儿客户端"
);
match crate::process_identity::kill_pid_forced(entry.pid) {
Ok(()) => report.killed += 1,
Err(e) => warn!(pid = entry.pid, error = %e, "击杀残留孤儿失败"),
}
}
Some(ident) => {
warn!(pid = entry.pid, ?ident, "登记进程身份不满足击杀谓词,跳过");
report.skipped_mismatch += 1;
}
}
}
let owner_pid = file_owner_pid(path);
let owner_dead = owner_pid.is_some_and(|pid| !crate::process_identity::pid_alive(pid));
let overage = now_secs().saturating_sub(file.born_at) >= ORPHAN_FILE_MIN_AGE_SECS;
let all_resolved = report_file_entries_resolved(&file);
if all_resolved && owner_dead && overage {
let _ = std::fs::remove_file(path);
report.files_removed += 1;
}
}
fn report_file_entries_resolved(file: &RegistryFile) -> bool {
file.entries.iter().all(|e| match process_identity(e.pid) {
None => true,
Some(ident) => ident.start_key != e.start_key || ident.ppid != e.spawn_ppid,
})
}
fn file_owner_pid(path: &Path) -> Option<u32> {
let stem = path.file_stem()?.to_str()?;
stem.rsplit_once('-')?.1.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"omniterm_registry_test_{tag}_{}_{}",
std::process::id(),
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&dir).expect("create temp dir");
dir
}
fn entry_of(pid: u32, spawn_ppid: u32, start_key: &str) -> ClientEntry {
ClientEntry::new(pid, spawn_ppid, start_key.to_string(), "lt_test")
}
fn spawn_fake_tmux_control_direct(sleep_secs: u32) -> std::process::Child {
Command::new("python3")
.args([
"-c",
&format!(
"import os; os.execv('/bin/bash', ['tmux', '-C', '-c', 'sleep {sleep_secs} & wait'])"
),
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn fake tmux -C")
}
fn spawn_fake_tmux_control_orphan(sleep_secs: u32) -> (u32, u32) {
let child = Command::new("sh")
.arg("-c")
.arg(format!(
"python3 -c \"import os; os.execv('/bin/bash', ['tmux', '-C', '-c', 'sleep {sleep_secs} & wait'])\" \
>/dev/null 2>&1 & echo $!; exit 0"
))
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn orphan maker");
let sh_pid = child.id();
let output = child.wait_with_output().expect("wait orphan maker");
let grandchild: u32 =
String::from_utf8_lossy(&output.stdout).trim().parse().expect("grandchild pid");
(grandchild, sh_pid)
}
fn wait_gone(pid: u32, budget: Duration) -> bool {
let deadline = Instant::now() + budget;
while Instant::now() < deadline {
if process_identity(pid).is_none() {
return true;
}
std::thread::sleep(Duration::from_millis(25));
}
false
}
fn start_key_of(pid: u32) -> String {
process_identity(pid).expect("identity").start_key
}
fn wait_argv0(pid: u32, argv0: &str) {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let ident = process_identity(pid).expect("identity");
if ident.argv.first().map(String::as_str) == Some(argv0) {
return;
}
assert!(Instant::now() < deadline, "execv 换影超时:argv={:?}", ident.argv);
std::thread::sleep(Duration::from_millis(10));
}
}
#[test]
fn register_deregister_roundtrip_writes_file_atomically() {
let dir = temp_dir("roundtrip");
let reg = ClientRegistry::open(dir.join("omniterm-dev-4242.clients"));
let mut child = spawn_fake_tmux_control_direct(30);
let pid = child.id();
assert_eq!(
reg.register(entry_of(pid, std::process::id(), &start_key_of(pid))),
RegisterOutcome::Registered
);
let file = load_file(reg.path()).expect("登记文件应已写出(tmp+rename)");
assert_eq!(file.v, 1);
assert_eq!(file.entries.len(), 1);
assert_eq!(file.entries[0].pid, pid);
assert_eq!(file.entries[0].spawn_ppid, std::process::id());
assert!(!file.entries[0].start_key.is_empty());
assert!(!reg.path().with_extension("clients.tmp").exists(), "tmp 文件应已被 rename 消费");
reg.deregister(pid);
assert!(reg.entries().is_empty());
assert_eq!(load_file(reg.path()).expect("文件仍在").entries.len(), 0);
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn registry_cap_rejects_overflow_after_pruning_dead() {
let dir = temp_dir("cap");
let reg = ClientRegistry::open(dir.join("omniterm-dev-1.clients"));
let dead = Command::new("true").spawn().expect("spawn true");
let dead_pid = dead.id();
let mut dead_child = dead;
let _ = dead_child.wait();
{
let mut entries = reg.inner.entries.lock().unwrap();
for i in 0..MAX_TRACKED_CLIENTS - 1 {
entries.push(entry_of(std::process::id(), 0, &format!("fake-{i}")));
}
entries.push(entry_of(dead_pid, 0, "dead"));
}
let mut first = spawn_fake_tmux_control_direct(30);
let first_pid = first.id();
let outcome =
reg.register(entry_of(first_pid, std::process::id(), &start_key_of(first_pid)));
assert_eq!(outcome, RegisterOutcome::Registered, "清死条目后有空位应放行");
assert_eq!(reg.entries().len(), MAX_TRACKED_CLIENTS, "长度恰为上限");
assert!(!reg.entries().iter().any(|e| e.pid == dead_pid), "死条目应被顺手清理");
let mut second = spawn_fake_tmux_control_direct(30);
let second_pid = second.id();
let outcome =
reg.register(entry_of(second_pid, std::process::id(), &start_key_of(second_pid)));
assert_eq!(outcome, RegisterOutcome::RefusedFull, "超限必须拒登新 spawn");
let entries = reg.entries();
assert_eq!(entries.len(), MAX_TRACKED_CLIENTS, "登记长度不得超过上限");
assert!(!entries.iter().any(|e| e.pid == second_pid), "超限项必须被拒(不落表)");
let file = load_file(reg.path()).expect("文件应已写");
assert!(file.entries.len() <= MAX_TRACKED_CLIENTS);
let _ = first.kill();
let _ = first.wait();
let _ = second.kill();
let _ = second.wait();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn session_names_are_truncated_on_utf8_boundary() {
let long = "会话名".repeat(200); let entry = ClientEntry::new(1, 1, "k".into(), &long);
assert!(entry.session.len() <= MAX_SESSION_BYTES);
assert!(!entry.session.is_empty(), "截断不得清空到不可读");
}
#[test]
fn reconcile_kills_fully_matching_orphan() {
let dir = temp_dir("kill");
let (orphan_pid, sh_pid) = spawn_fake_tmux_control_orphan(120);
wait_argv0(orphan_pid, "tmux"); std::thread::sleep(Duration::from_millis(100)); let key = start_key_of(orphan_pid);
let reg =
ClientRegistry::open(dir.join(format!("omniterm-dev-{}.clients", std::process::id())));
reg.register(entry_of(orphan_pid, sh_pid, &key));
let report = reconcile_dir(&dir);
assert_eq!(report.killed, 1, "谓词全通过的孤儿应被击杀: {report:?}");
assert!(wait_gone(orphan_pid, Duration::from_secs(5)), "孤儿应在时限内消失");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reconcile_skips_pid_reuse_without_killing() {
let dir = temp_dir("reuse");
let mut child = spawn_fake_tmux_control_direct(120);
let pid = child.id();
let reg =
ClientRegistry::open(dir.join(format!("omniterm-dev-{}.clients", std::process::id())));
reg.register(entry_of(pid, 0, "bogus-start-key"));
let report = reconcile_dir(&dir);
assert_eq!(report.killed, 0, "start_key 不符不得击杀: {report:?}");
assert_eq!(report.skipped_reuse, 1);
assert!(process_identity(pid).is_some(), "进程必须存活(不误杀)");
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reconcile_keeps_clients_still_owned_by_live_parent() {
let dir = temp_dir("keep");
let mut child = spawn_fake_tmux_control_direct(120);
let pid = child.id();
wait_argv0(pid, "tmux"); let reg =
ClientRegistry::open(dir.join(format!("omniterm-dev-{}.clients", std::process::id())));
reg.register(entry_of(pid, std::process::id(), &start_key_of(pid)));
let report = reconcile_dir(&dir);
assert_eq!(report.killed, 0, "活跃父进程的客户端不得被击杀: {report:?}");
assert_eq!(report.kept, 1);
assert!(process_identity(pid).is_some(), "进程必须存活(不误杀)");
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reconcile_skips_non_control_argv() {
let dir = temp_dir("argv");
let mut child = Command::new("python3")
.args([
"-c",
"import os; os.execv('/bin/bash', ['tmux -C.md', '-c', 'sleep 120 & wait'])",
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn fake vim arg");
let pid = child.id();
wait_argv0(pid, "tmux -C.md"); let reg =
ClientRegistry::open(dir.join(format!("omniterm-dev-{}.clients", std::process::id())));
reg.register(entry_of(pid, 0, &start_key_of(pid)));
let report = reconcile_dir(&dir);
assert_eq!(report.killed, 0, "argv 不符不得击杀: {report:?}");
assert_eq!(report.skipped_mismatch, 1);
assert!(process_identity(pid).is_some(), "进程必须存活(不误杀)");
let _ = child.kill();
let _ = child.wait();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reconcile_removes_expired_orphan_registry_file() {
let dir = temp_dir("gc");
let mut true_child = Command::new("true").spawn().expect("spawn true");
let owner_pid = true_child.id();
let _ = true_child.wait();
let path = dir.join(format!("omniterm-dev-{owner_pid}.clients"));
save_file(&path, now_secs() - ORPHAN_FILE_MIN_AGE_SECS - 1, &[]).expect("write file");
let report = reconcile_dir(&dir);
assert_eq!(report.files_removed, 1, "超期空孤儿文件应被回收: {report:?}");
assert!(!path.exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reconcile_keeps_fresh_empty_file() {
let dir = temp_dir("gc_fresh");
let path = dir.join(format!("omniterm-dev-{}.clients", std::process::id()));
save_file(&path, now_secs(), &[]).expect("write file");
let report = reconcile_dir(&dir);
assert_eq!(report.files_removed, 0, "未超龄不得回收: {report:?}");
assert!(path.exists());
let _ = std::fs::remove_dir_all(&dir);
}
}