pub mod classify;
pub mod heal;
pub mod orphan;
pub mod probe;
#[cfg(test)]
pub(crate) mod test_support;
use std::sync::{Arc, Mutex, OnceLock};
use chrono::{DateTime, Utc};
use tracing::{debug, info, warn};
use classify::ServerHealth;
pub const DEAF_PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
pub const DEAF_CONFIRM_COUNT: u32 = 3;
pub const ORPHAN_WARN_THRESHOLD: u32 = 5;
pub const CONSECUTIVE_DEAF_CAP: u32 = u32::MAX;
#[derive(Debug, Clone)]
pub struct HealthSnapshot {
pub state: ServerHealth,
pub consecutive_deaf: u32,
pub last_deaf_at: Option<DateTime<Utc>>,
pub orphan_count: u32,
}
impl Default for HealthSnapshot {
fn default() -> Self {
Self {
state: ServerHealth::Other,
consecutive_deaf: 0,
last_deaf_at: None,
orphan_count: 0,
}
}
}
pub struct HealthState {
inner: Arc<Inner>,
}
struct Inner {
snap: Mutex<HealthSnapshot>,
heal_lock: tokio::sync::Mutex<()>,
}
impl Clone for HealthState {
fn clone(&self) -> Self {
Self { inner: Arc::clone(&self.inner) }
}
}
impl HealthState {
pub fn new() -> Self {
Self {
inner: Arc::new(Inner {
snap: Mutex::new(HealthSnapshot::default()),
heal_lock: tokio::sync::Mutex::new(()),
}),
}
}
pub fn snapshot(&self) -> HealthSnapshot {
self.inner.snap.lock().expect("健康状态锁中毒").clone()
}
pub fn record(&self, health: ServerHealth, orphan_count: u32) -> HealthSnapshot {
let mut snap = self.inner.snap.lock().expect("健康状态锁中毒");
let prev_orphan = snap.orphan_count;
snap.state = health;
if health == ServerHealth::Deaf {
snap.consecutive_deaf = next_deaf_streak(snap.consecutive_deaf);
snap.last_deaf_at = Some(Utc::now());
if snap.consecutive_deaf.is_multiple_of(DEAF_CONFIRM_COUNT) {
warn!(
stage = "monitor",
consecutive_deaf = snap.consecutive_deaf,
orphan_count,
"tmux server 连续 {} 次探针判聋(deaf server 半死态):server_exit=1,所有新 tmux 命令会失败且无人值守下无限期持续;\
用 POST /api/v1/tmux/rebuild 重建 tmux server 自愈",
snap.consecutive_deaf
);
}
} else {
snap.consecutive_deaf = 0;
}
snap.orphan_count = orphan_count;
if orphan_count > ORPHAN_WARN_THRESHOLD && orphan_count > prev_orphan {
warn!(
stage = "monitor",
orphan_count,
threshold = ORPHAN_WARN_THRESHOLD,
"tmux -C 孤儿客户端堆积超阈值({} > {}):tmux server 已进入「一 SIGTERM 就假死」高危态——\
停止 drain 的孤儿会把下一次 SIGTERM 后的 server 关闭流程冻结成聋 server(先兆指标,尽快清理)",
orphan_count,
ORPHAN_WARN_THRESHOLD
);
}
snap.clone()
}
pub fn heal_mutex(&self) -> &tokio::sync::Mutex<()> {
&self.inner.heal_lock
}
}
impl Default for HealthState {
fn default() -> Self {
Self::new()
}
}
fn next_deaf_streak(prev: u32) -> u32 {
if prev == CONSECUTIVE_DEAF_CAP { CONSECUTIVE_DEAF_CAP } else { prev.saturating_add(1) }
}
static GLOBAL: OnceLock<HealthState> = OnceLock::new();
pub fn init_global() -> HealthState {
let state = HealthState::new();
let _ = GLOBAL.set(state.clone());
state
}
pub fn global() -> Option<HealthState> {
GLOBAL.get().cloned()
}
pub fn spawn_monitor() {
let state = global().unwrap_or_else(init_global);
info!(
interval_secs = DEAF_PROBE_INTERVAL.as_secs(),
confirm_count = DEAF_CONFIRM_COUNT,
orphan_warn_threshold = ORPHAN_WARN_THRESHOLD,
"启动 tmux server 健康监控(聋 server 检测 + 孤儿堆积先兆;docs/dev/plans/2026-09-22-tmux-server-shutdown-hang.md P1-1/P1-2)"
);
tokio::spawn(async move {
loop {
monitor_tick(&state).await;
tokio::time::sleep(DEAF_PROBE_INTERVAL).await;
}
});
}
async fn monitor_tick(state: &HealthState) {
let probe = probe::probe().await;
let orphans = orphan::count_orphans();
debug!(
stage = "monitor",
health = %probe.health,
success = ?probe.command.as_ref().map(|c| c.success),
exit_code = ?probe.command.as_ref().and_then(|c| c.exit_code),
stdout = ?probe.command.as_ref().map(|c| c.stdout.as_str()),
stderr = ?probe.command.as_ref().map(|c| c.stderr.as_str()),
socket = ?probe.socket,
orphan_count = orphans.total,
tracked = orphans.tracked,
untracked = orphans.untracked,
scan_degraded = orphans.scan_degraded,
"tmux 健康巡检"
);
state.record(probe.health, orphans.total);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deaf_streak_counts_to_threshold_and_resets() {
let state = HealthState::new();
let snap = state.snapshot();
assert_eq!(snap.consecutive_deaf, 0);
assert!(snap.last_deaf_at.is_none(), "初始未判聋");
assert_eq!(snap.state, ServerHealth::Other, "未探测初始值不触发任何动作");
for i in 1..=DEAF_CONFIRM_COUNT {
let snap = state.record(ServerHealth::Deaf, 0);
assert_eq!(snap.consecutive_deaf, i);
}
assert!(state.snapshot().last_deaf_at.is_some(), "判聋须留时间戳");
let before = state.snapshot().last_deaf_at;
let snap = state.record(ServerHealth::Healthy, 0);
assert_eq!(snap.consecutive_deaf, 0, "非 Deaf 清零");
assert_eq!(snap.last_deaf_at, before, "last_deaf_at 是历史标记,恢复后保留");
assert_eq!(snap.state, ServerHealth::Healthy);
}
#[test]
fn deaf_streak_saturates_at_cap() {
assert_eq!(next_deaf_streak(0), 1);
assert_eq!(
next_deaf_streak(CONSECUTIVE_DEAF_CAP),
CONSECUTIVE_DEAF_CAP,
"超限策略 = 饱和,不回绕"
);
}
#[test]
fn record_updates_orphan_count() {
let state = HealthState::new();
let snap = state.record(ServerHealth::NoServer, ORPHAN_WARN_THRESHOLD + 3);
assert_eq!(snap.orphan_count, ORPHAN_WARN_THRESHOLD + 3);
assert_eq!(snap.state, ServerHealth::NoServer);
}
}