use std::path::Path;
use tracing::{info, warn};
use super::HealthState;
use super::classify::ServerHealth;
use super::probe::{self, ProbeResult};
use crate::process_identity::kill_pid_forced;
#[derive(Debug, Clone)]
pub struct HealOutcome {
pub server_pid: Option<u32>,
pub socket_removed: bool,
pub detail: String,
}
#[derive(Debug)]
pub enum HealError {
NotDeaf(ServerHealth),
InProgress,
OwnerUnresolved(String),
KillFailed { pid: u32, source: std::io::Error },
}
impl HealError {
pub fn detail(&self) -> String {
match self {
HealError::NotDeaf(health) => {
format!("re-probe did not confirm deaf (state: {health})")
}
HealError::InProgress => "another rebuild is already in progress".to_string(),
HealError::OwnerUnresolved(reason) => format!("tmux server pid unresolved: {reason}"),
HealError::KillFailed { pid, source } => {
format!("SIGKILL tmux server pid {pid} failed: {source}")
}
}
}
}
#[derive(Debug, Clone)]
pub struct ServerProcess {
pub pid: u32,
pub exe_name: String,
}
pub async fn heal(state: &HealthState) -> Result<HealOutcome, HealError> {
heal_at(state, probe::socket_path().as_deref(), probe::probe()).await
}
async fn heal_at(
state: &HealthState,
socket_path: Option<&Path>,
reprobe: impl std::future::Future<Output = ProbeResult>,
) -> Result<HealOutcome, HealError> {
let _guard = state.heal_mutex().try_lock().map_err(|_| HealError::InProgress)?;
let probe = reprobe.await;
if probe.health != ServerHealth::Deaf {
info!(
stage = "reprobe",
health = %probe.health,
"重建 tmux server 中止:重探针未确认聋(防陈旧状态/自动重建后误杀)"
);
return Err(HealError::NotDeaf(probe.health));
}
let Some(socket_path) = socket_path else {
return Err(HealError::OwnerUnresolved(
"本平台无 tmux socket 路径语义(Windows/psmux),反查不可用——放弃击杀".to_string(),
));
};
info!(stage = "reprobe", health = "deaf", socket_path = %socket_path.display(), "重建 tmux server:重探针确认聋,开始自愈");
let owner = find_socket_owner(socket_path).map_err(|reason| {
warn!(stage = "resolve", socket_path = %socket_path.display(), %reason, "重建 tmux server 放弃击杀");
HealError::OwnerUnresolved(reason)
})?;
info!(
stage = "resolve",
pid = owner.pid,
exe = %owner.exe_name,
socket_path = %socket_path.display(),
"反查到 tmux server 进程(exe 身份已复核)"
);
kill_pid_forced(owner.pid)
.map_err(|source| HealError::KillFailed { pid: owner.pid, source })?;
info!(stage = "kill", pid = owner.pid, socket_path = %socket_path.display(), "已对 tmux server 发送 SIGKILL");
let (socket_removed, cleanup_note) = match std::fs::remove_file(socket_path) {
Ok(()) => (true, "stale socket removed"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => (true, "stale socket already gone"),
Err(e) => {
warn!(
stage = "socket_cleanup",
pid = owner.pid,
socket_path = %socket_path.display(),
error = %e,
"删除 stale socket 失败"
);
return Ok(HealOutcome {
server_pid: Some(owner.pid),
socket_removed: false,
detail: format!(
"killed deaf tmux server pid {}; stale socket removal failed: {e}; next tmux command auto-rebuilds the server",
owner.pid
),
});
}
};
info!(
stage = "socket_cleanup",
pid = owner.pid,
socket_path = %socket_path.display(),
socket_removed,
"{cleanup_note}"
);
Ok(HealOutcome {
server_pid: Some(owner.pid),
socket_removed,
detail: format!(
"killed deaf tmux server pid {}; {cleanup_note}; next tmux command auto-rebuilds the server",
owner.pid
),
})
}
pub fn find_socket_owner(socket_path: &Path) -> Result<ServerProcess, String> {
platform::find_owner_impl(socket_path)
}
#[cfg(target_os = "linux")]
fn exe_is_tmux(exe_link: &str) -> bool {
Path::new(exe_link).file_name().map(|n| n == "tmux").unwrap_or(false)
}
#[cfg(target_os = "linux")]
fn split_head_columns(line: &str, n: usize) -> Option<(Vec<&str>, &str)> {
let mut rest = line;
let mut cols = Vec::with_capacity(n);
for _ in 0..n {
rest = rest.trim_start();
if rest.is_empty() {
return None;
}
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
cols.push(&rest[..end]);
rest = &rest[end..];
}
Some((cols, rest.trim_start()))
}
#[cfg(target_os = "linux")]
fn parse_unix_row(line: &str) -> Option<(String, u64, String)> {
let (cols, path) = split_head_columns(line, 7)?;
let st = cols[5].to_string();
let inode = cols[6].parse::<u64>().ok()?;
Some((st, inode, path.to_string()))
}
#[cfg(target_os = "linux")]
fn find_listen_inode(proc_net_unix: &str, socket_path: &str) -> Option<u64> {
proc_net_unix
.lines()
.filter_map(parse_unix_row)
.find(|(st, _inode, path)| st == UNIX_ST_LISTEN && path == socket_path)
.map(|(_st, inode, _path)| inode)
}
#[cfg(target_os = "linux")]
const UNIX_ST_LISTEN: &str = "01";
#[cfg(target_os = "linux")]
const TMUX_SERVER_EXE_BASENAME: &str = "tmux";
#[cfg(target_os = "linux")]
mod platform {
use super::*;
use std::path::PathBuf;
pub fn find_owner_impl(socket_path: &Path) -> Result<ServerProcess, String> {
let text = std::fs::read_to_string("/proc/net/unix")
.map_err(|e| format!("读 /proc/net/unix 失败:{e}"))?;
let path_str = socket_path.to_string_lossy();
let inode = find_listen_inode(&text, &path_str).ok_or_else(|| {
format!("{path_str} 上无 LISTEN socket(server 已消亡或路径不符)——放弃击杀")
})?;
let owners = find_fd_owners(inode);
let verified: Vec<ServerProcess> = owners
.iter()
.filter(|(_, exe_name)| exe_is_tmux(exe_name))
.map(|(pid, exe_name)| ServerProcess { pid: *pid, exe_name: exe_name.clone() })
.collect();
match verified.as_slice() {
[only] => Ok(only.clone()),
[] => Err(format!(
"socket inode {inode} 无属主进程或 exe 身份复核不过(要求 exe 基名 == \"{TMUX_SERVER_EXE_BASENAME}\"),候选 {owners:?}——放弃击杀"
)),
many => Err(format!(
"socket inode {inode} 有 {} 个 tmux 身份属主,无法唯一确定——放弃击杀",
many.len()
)),
}
}
fn find_fd_owners(inode: u64) -> Vec<(u32, String)> {
let want = format!("socket:[{inode}]");
let Ok(proc_dir) = std::fs::read_dir("/proc") else {
return Vec::new();
};
let mut owners = Vec::new();
for dirent in proc_dir.flatten() {
let Ok(pid) = dirent.file_name().to_string_lossy().parse::<u32>() else {
continue;
};
let Ok(fd_dir) = std::fs::read_dir(format!("/proc/{pid}/fd")) else {
continue;
};
let holds = fd_dir
.flatten()
.any(|fd| std::fs::read_link(fd.path()).map(|l| l == want).unwrap_or(false));
if holds {
let exe_name = std::fs::read_link(format!("/proc/{pid}/exe"))
.unwrap_or_else(|_| PathBuf::from("?"))
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "?".to_string());
owners.push((pid, exe_name));
}
}
owners
}
}
#[cfg(not(target_os = "linux"))]
mod platform {
use super::*;
pub fn find_owner_impl(socket_path: &Path) -> Result<ServerProcess, String> {
warn!(
socket_path = %socket_path.display(),
"非 Linux 平台无 /proc,socket inode 反查不可用:重建 tmux server 降级为拒绝执行(不猜 PID)"
);
Err("socket inode lookup unavailable on this platform (requires /proc)".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::health::test_support::{FakeSocketHolder, temp_dir, wait_child_exit};
use crate::health::{HealthState, probe::ProbeResult};
const EXE_BASENAME: &str = "tmux";
fn deaf_probe() -> ProbeResult {
ProbeResult { health: ServerHealth::Deaf, command: None, socket: None }
}
fn probe_with(health: ServerHealth) -> ProbeResult {
ProbeResult { health, command: None, socket: None }
}
#[tokio::test]
async fn heal_refuses_when_reprobe_not_deaf() {
let mut holder = FakeSocketHolder::spawn("heal_refuse", EXE_BASENAME);
for health in [ServerHealth::Healthy, ServerHealth::NoServer, ServerHealth::Other] {
let state = HealthState::new();
let err =
heal_at(&state, Some(&holder.socket_path), std::future::ready(probe_with(health)))
.await
.expect_err("非 deaf 必须拒绝自愈");
assert!(
matches!(err, HealError::NotDeaf(h) if h == health),
"health={health:?} got {err:?}"
);
}
assert!(
holder.child.try_wait().unwrap().is_none(),
"健康 server 不得被命中(进程必须存活)"
);
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn heal_kills_verified_owner_removes_socket_then_refuses_rebuilt_server() {
let mut holder = FakeSocketHolder::spawn("heal_kill", EXE_BASENAME);
let pid = holder.child.id();
let state = HealthState::new();
let owner = find_socket_owner(&holder.socket_path).expect("应反查到属主");
assert_eq!(owner.pid, pid);
assert_eq!(owner.exe_name, EXE_BASENAME);
let outcome = heal_at(&state, Some(&holder.socket_path), std::future::ready(deaf_probe()))
.await
.expect("重探针确认聋应可自愈");
assert_eq!(outcome.server_pid, Some(pid));
assert!(outcome.socket_removed);
assert!(wait_child_exit(&mut holder.child), "server 应被 SIGKILL 并退出");
assert!(!holder.socket_path.exists(), "stale socket 应被删除");
let err = heal_at(
&state,
Some(&holder.socket_path),
std::future::ready(probe_with(ServerHealth::Healthy)),
)
.await
.expect_err("重建后的健康 server 不得再被命中");
assert!(matches!(err, HealError::NotDeaf(ServerHealth::Healthy)));
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn heal_aborts_without_killing_when_owner_identity_mismatched() {
let mut holder = FakeSocketHolder::spawn("heal_mismatch", "python3");
let state = HealthState::new();
let err = heal_at(&state, Some(&holder.socket_path), std::future::ready(deaf_probe()))
.await
.expect_err("身份复核不过必须放弃击杀");
assert!(matches!(err, HealError::OwnerUnresolved(_)));
assert!(holder.child.try_wait().unwrap().is_none(), "身份不符进程不得被杀");
}
#[tokio::test]
async fn heal_aborts_when_socket_owner_unresolved() {
let dir = temp_dir("heal_no_sock");
let state = HealthState::new();
let err =
heal_at(&state, Some(&dir.join("missing.sock")), std::future::ready(deaf_probe()))
.await
.expect_err("找不到 server 必须放弃击杀");
assert!(matches!(err, HealError::OwnerUnresolved(_)));
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn heal_single_flight_rejects_concurrent_trigger_immediately() {
let state = HealthState::new();
let (started_tx, started_rx) = tokio::sync::oneshot::channel::<()>();
let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>();
let first_state = state.clone();
let first = tokio::spawn(async move {
heal_at(&first_state, None, async move {
let _ = started_tx.send(());
let _ = release_rx.await;
probe_with(ServerHealth::Other) })
.await
});
started_rx.await.expect("first 应已持单飞锁进入重探针");
let second = heal_at(&state, None, std::future::ready(deaf_probe())).await;
assert!(
matches!(second, Err(HealError::InProgress)),
"进行中的第二次触发必须立即 in_progress: {second:?}"
);
let _ = release_tx.send(());
let first_res = first.await.expect("join first");
assert!(matches!(first_res, Err(HealError::NotDeaf(ServerHealth::Other))));
}
#[cfg(target_os = "linux")]
#[test]
fn find_listen_inode_filters_connected_rows_sharing_path() {
let sample = "\
Num RefCount Protocol Flags Type St Inode Path
0000000000000000: 00000002 00000000 00010000 0001 01 479162424 /tmp/tmux-1000/default
0000000000000000: 00000003 00000000 00000000 0001 03 479163467 /tmp/tmux-1000/default
0000000000000000: 00000002 00000000 00010000 0001 01 32583 /var/run/docker/metrics.sock
";
assert_eq!(find_listen_inode(sample, "/tmp/tmux-1000/default"), Some(479162424));
assert_eq!(find_listen_inode(sample, "/var/run/docker/metrics.sock"), Some(32583));
assert_eq!(
find_listen_inode(sample, "/tmp/absent.sock"),
None,
"找不到 ⇒ None(调用方放弃击杀)"
);
}
#[cfg(target_os = "linux")]
#[test]
fn parse_unix_row_keeps_path_with_spaces() {
let row = "0000000000000000: 00000002 00000000 00010000 0001 01 42 /tmp/a b/s.sock";
let (st, inode, path) = parse_unix_row(row).expect("should parse");
assert_eq!(st, "01");
assert_eq!(inode, 42);
assert_eq!(path, "/tmp/a b/s.sock");
assert!(parse_unix_row("short row").is_none());
}
#[cfg(target_os = "linux")]
#[test]
fn exe_identity_check_uses_basename_only() {
assert!(exe_is_tmux("/usr/bin/tmux"));
assert!(exe_is_tmux("tmux"));
assert!(!exe_is_tmux("/usr/bin/tmux: server"), "setproctitle 形态不是 exe 基名判据的目标");
assert!(!exe_is_tmux("/usr/bin/python3"));
assert!(!exe_is_tmux(""));
}
}