From 107031dcc5250bd234a477932e0c09f97ee0d213 Mon Sep 17 00:00:00 2001 From: pjh456 <147148383@qq.com> Date: Tue, 6 Oct 2026 20:00:47 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20server=20=E5=90=AF=E5=8A=A8=E6=97=B6=20?= =?UTF-8?q?liveness=20=E6=89=AB=E6=8F=8F=EF=BC=8C=E5=83=B5=E5=B0=B8?= =?UTF-8?q?=E6=80=81=E8=87=AA=E5=8A=A8=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 36 +-- Cargo.toml | 2 +- crates/oxide_cli/src/server/cleanup.rs | 16 +- crates/oxide_cli/src/server/liveness.rs | 406 ++++++++++++++++++++++++ crates/oxide_cli/src/server/mod.rs | 7 +- crates/oxide_cli/src/server/server.rs | 64 ++-- 6 files changed, 483 insertions(+), 48 deletions(-) create mode 100644 crates/oxide_cli/src/server/liveness.rs diff --git a/Cargo.lock b/Cargo.lock index 4d2eef23..6d5ee309 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1160,7 +1160,7 @@ dependencies = [ [[package]] name = "oxide_builtins" -version = "0.8.42" +version = "0.8.43" dependencies = [ "chrono", "fast-float", @@ -1185,7 +1185,7 @@ dependencies = [ [[package]] name = "oxide_bytecode" -version = "0.8.42" +version = "0.8.43" dependencies = [ "num-bigint", "oxide_log", @@ -1193,7 +1193,7 @@ dependencies = [ [[package]] name = "oxide_cfg" -version = "0.8.42" +version = "0.8.43" dependencies = [ "oxide_bytecode", "oxide_emit", @@ -1204,7 +1204,7 @@ dependencies = [ [[package]] name = "oxide_cli" -version = "0.8.42" +version = "0.8.43" dependencies = [ "ansi_term", "bumpalo", @@ -1225,7 +1225,7 @@ dependencies = [ [[package]] name = "oxide_code_cache" -version = "0.8.42" +version = "0.8.43" dependencies = [ "lru", "oxide_bytecode", @@ -1234,7 +1234,7 @@ dependencies = [ [[package]] name = "oxide_compiler" -version = "0.8.42" +version = "0.8.43" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1253,7 +1253,7 @@ dependencies = [ [[package]] name = "oxide_dce" -version = "0.8.42" +version = "0.8.43" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1267,7 +1267,7 @@ dependencies = [ [[package]] name = "oxide_emit" -version = "0.8.42" +version = "0.8.43" dependencies = [ "num-bigint", "oxc_allocator", @@ -1282,7 +1282,7 @@ dependencies = [ [[package]] name = "oxide_ir" -version = "0.8.42" +version = "0.8.43" dependencies = [ "oxide_bytecode", "oxide_log", @@ -1291,7 +1291,7 @@ dependencies = [ [[package]] name = "oxide_kernel" -version = "0.8.42" +version = "0.8.43" dependencies = [ "bumpalo", "dashmap", @@ -1306,7 +1306,7 @@ dependencies = [ [[package]] name = "oxide_liveness" -version = "0.8.42" +version = "0.8.43" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1318,7 +1318,7 @@ dependencies = [ [[package]] name = "oxide_log" -version = "0.8.42" +version = "0.8.43" dependencies = [ "tracing", "tracing-appender", @@ -1327,7 +1327,7 @@ dependencies = [ [[package]] name = "oxide_parser" -version = "0.8.42" +version = "0.8.43" dependencies = [ "oxc_allocator", "oxc_ast", @@ -1342,7 +1342,7 @@ dependencies = [ [[package]] name = "oxide_regalloc" -version = "0.8.42" +version = "0.8.43" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1358,7 +1358,7 @@ dependencies = [ [[package]] name = "oxide_runtime_api" -version = "0.8.42" +version = "0.8.43" dependencies = [ "num-bigint", "num-traits", @@ -1370,7 +1370,7 @@ dependencies = [ [[package]] name = "oxide_test262" -version = "0.8.42" +version = "0.8.43" dependencies = [ "oxide_bytecode", "oxide_compiler", @@ -1387,7 +1387,7 @@ dependencies = [ [[package]] name = "oxide_types" -version = "0.8.42" +version = "0.8.43" dependencies = [ "bumpalo", "hashbrown 0.14.5", @@ -1399,7 +1399,7 @@ dependencies = [ [[package]] name = "oxide_vm" -version = "0.8.42" +version = "0.8.43" dependencies = [ "bumpalo", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 05918887..39770b70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ members = [ ] [workspace.package] -version = "0.8.42" +version = "0.8.43" edition = "2021" license = "MIT" rust-version = "1.80" diff --git a/crates/oxide_cli/src/server/cleanup.rs b/crates/oxide_cli/src/server/cleanup.rs index 98e05188..57bc8dd8 100644 --- a/crates/oxide_cli/src/server/cleanup.rs +++ b/crates/oxide_cli/src/server/cleanup.rs @@ -14,6 +14,9 @@ //! sidecar 消失为接管信号,届时 socket 文件必已删除)。 //! - 两条保守拒绝底线:socket 存活但 PID 无法可靠确定、杀进程超时,均不碰 //! 任何文件(拒绝清理不会双注册、不会误删存活 server 的文件)。 +//! - 裁决表与杀进程原语是 `liveness` 模块(启动时扫描)的第二消费方: +//! 自动场景在 `decide` 分派前加 socket 存活守卫(存活即无操作),Kill 行 +//! 的自动语义是僵尸态自动恢复,人工语义是主动清理。 use std::fs; use std::path::Path; @@ -41,7 +44,7 @@ pub enum CleanupOutcome { /// 裁决表决策:四态。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Decision { +pub enum Decision { /// 无残留,无操作。 None, /// 只删文件(无存活监听者,无需杀进程)。 @@ -68,7 +71,10 @@ enum Decision { /// /// # 注意事项 /// - 纯函数,无 I/O,全部分支可单测。 -fn decide( +/// - 第二消费方是启动时 liveness 扫描(自动场景):自动场景在分派前加 +/// socket 存活守卫,存活即无操作(`Kill` 行的自动语义是僵尸态恢复, +/// 人工语义是主动清理)。 +pub fn decide( sidecar: Option<&ServerIdentity>, socket_exists: bool, socket_alive: bool, pid_alive: bool, starttime_match: bool, ) -> Decision { // socket 存活:只有 sidecar 可解析、PID 活且刻度匹配才证明本进程是 @@ -196,7 +202,7 @@ pub fn well_known_cleanup() -> CleanupOutcome { /// # 边界与前提 /// - `kill` 二进制缺失或进程不存在时返回 `false`(信号未确认送达,调用方 /// 保守判杀进程失败)。 -fn send_sigterm(pid: u32) -> bool { +pub fn send_sigterm(pid: u32) -> bool { Command::new("kill") .args(["-TERM", &pid.to_string()]) .status() @@ -205,7 +211,7 @@ fn send_sigterm(pid: u32) -> bool { } /// 轮询进程退出:100 毫秒间隔 `pid_alive` 检查,退出返回真,超时返回假。 -fn wait_pid_exit(pid: u32, timeout: Duration) -> bool { +pub fn wait_pid_exit(pid: u32, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; loop { if !sidecar::pid_alive(pid) { @@ -224,7 +230,7 @@ fn wait_pid_exit(pid: u32, timeout: Duration) -> bool { /// # 副作用 /// - 可能删除 socket 文件与 sidecar 文件;文件已不存在不视为错误(server /// 自身退出序列可能已删过)。 -fn remove_files(sidecar_path: &Path, socket_path: &Path) { +pub fn remove_files(sidecar_path: &Path, socket_path: &Path) { let _ = fs::remove_file(socket_path); let _ = fs::remove_file(sidecar_path); } diff --git a/crates/oxide_cli/src/server/liveness.rs b/crates/oxide_cli/src/server/liveness.rs new file mode 100644 index 00000000..ecff1f9a --- /dev/null +++ b/crates/oxide_cli/src/server/liveness.rs @@ -0,0 +1,406 @@ +//! 启动时 liveness 扫描:扫陈旧 sidecar 与 socket,僵尸态自动恢复。 +//! +//! 关键约定: +//! - 扫描插在 `run_server` 启动序列第一步(`claim_sidecar` 之前、信号处理器 +//! 注册之前):认领裁决前清掉陈旧残留。僵尸态(sidecar 可解析、PID 活、 +//! 启动时刻刻度匹配、socket 死)是 `claim_sidecar` 唯一保守拒绝阻塞再注册 +//! 之态,本模块自动解开,不再依赖人工清理命令。 +//! - socket 存活时一律无操作:存活 server 的处置归 `claim_sidecar` 裁决(同 +//! 版本拒绝启动、异版本走交接)。自动扫描绝不杀存活 server——`decide` 表 +//! 的 Kill 行人工清理语义,自动场景杀存活 server 等于抢占单例,违反单例 +//! 交接协议。 +//! - 只删 `config` 注入的 sidecar 与 socket 两条路径;日志文件不碰(跨重启 +//! 保留的诊断工件,不在清理范围)。`--rm` 路径不调用本模块(不碰全局 +//! well-known 路径的不变量)。 +//! - 杀进程唯一前置是启动时刻刻度匹配(沿用清理命令裁决):刻度自开机单调 +//! 递增,PID 复用必然失配,无新增误杀风险。 +//! - 僵尸分支两段式:先有界等待 sidecar 消失(覆盖旧 server 正在退出序列中 +//! 的窗口,排水安全超时 5 秒必在窗内),仍在则发 SIGTERM 等退出后删文件; +//! 杀进程失败不碰任何文件,映射保守拒绝并指向人工清理命令。 + +use std::path::Path; +use std::time::{Duration, Instant}; + +use super::cleanup::{self, Decision}; +use super::sidecar; + +/// 僵尸退出窗口:有界等待旧 server 的 sidecar 消失,10 秒(与 restart 臂等 +/// 旧 server 退出的窗口同值),长于旧 server 的排水安全超时 5 秒。 +const ZOMBIE_EXIT_WINDOW: Duration = Duration::from_secs(10); + +/// 僵尸退出窗口轮询间隔:50 毫秒(与交接等待同值)。 +const ZOMBIE_POLL_INTERVAL: Duration = Duration::from_millis(50); + +/// 扫描结果:三态。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LivenessOutcome { + /// 无操作:无残留,或 socket 存活(存活 server 归 `claim_sidecar` 裁决)。 + Noop, + /// 已清理:陈旧文件已删(可能含杀僵尸进程)。 + Cleaned, + /// 杀进程失败:SIGTERM 已发但进程在窗口内未退出,文件未触碰。 + KillFailed, +} + +/// 启动时 liveness 扫描入口:扫 sidecar 与 socket,清理陈旧残留,僵尸态自动恢复。 +/// +/// # 步骤 +/// 1. 采集五个探活信号(sidecar 可解析性、socket 存活、socket 文件存在、 +/// PID 存活、刻度匹配)。 +/// 2. socket 存活守卫:存活即无操作(自动扫描绝不杀存活 server)。 +/// 3. 按 `decide` 裁决表分派:无操作 / 只删文件 / 僵尸分支 / 拒绝。 +/// +/// # 边界与前提 +/// - 只删 `config` 注入的 sidecar 与 socket 两条路径;日志文件不碰。 +/// - 杀进程等待取缺省 10 秒(长于 server 的在途排水安全超时 5 秒)。 +/// +/// # 副作用 +/// - 可能向 sidecar 记录的进程发 SIGTERM;可能删除 socket 与 sidecar 文件。 +/// +/// # 注意事项 +/// - 须在 `claim_sidecar` 之前调用(认领裁决前清掉陈旧状态)。 +pub fn liveness_scan(sidecar_path: &Path, socket_path: &Path) -> LivenessOutcome { + liveness_scan_with_waits(sidecar_path, socket_path, ZOMBIE_EXIT_WINDOW, cleanup::DEFAULT_KILL_WAIT) +} + +/// 启动时 liveness 扫描(可注入两个超时,测试注入短窗口)。 +/// +/// # 步骤 +/// 1. 采集信号:`read_identity`(缺失与损坏同归 `None`)、socket 文件存在、 +/// `is_server_alive`(仅文件存在时探活)、`pid_alive`、`process_starttime` +/// 与 sidecar 记录值比对(PID 死时匹配位恒假)。 +/// 2. socket 存活守卫:存活即无操作。 +/// 3. 按 `decide` 裁决表分派: +/// - `None` 无操作。 +/// - `DeleteOnly` 删文件(socket 先、sidecar 后)。 +/// - `Kill` 僵尸分支:有界等待 sidecar 消失,仍在则杀进程后删文件。 +/// - `Refuse` 无操作(文件不碰,归 `claim_sidecar` 裁决)。 +/// +/// # 边界与前提 +/// - `zombie_exit_window` 是僵尸分支等 sidecar 消失的窗口;`kill_wait` 是 +/// SIGTERM 送达后等进程退出的窗口。 +/// - 僵尸退出窗口长于旧 server 的排水安全超时(5 秒),退出序列中的旧 server +/// 的 sidecar 必在窗口内消失,杀进程不触发。 +/// +/// # 副作用 +/// - 可能向 sidecar 记录的进程发 SIGTERM;可能删除 socket 与 sidecar 文件。 +/// - 杀进程决策与失败记入跨重启保留的日志文件(守护形态 stderr 是 null)。 +pub fn liveness_scan_with_waits( + sidecar_path: &Path, socket_path: &Path, zombie_exit_window: Duration, kill_wait: Duration, +) -> LivenessOutcome { + // 采集信号:sidecar 可解析性(缺失与损坏同归 None)、socket 存活、 + // PID 存活与刻度匹配(PID 死时匹配位恒假)。 + let identity = sidecar::read_identity(sidecar_path); + let socket_exists = socket_path.exists(); + let socket_alive = socket_exists && sidecar::is_server_alive(socket_path); + let (pid_alive, starttime_match) = match &identity { + Some(id) => { + let alive = sidecar::pid_alive(id.pid); + (alive, alive && sidecar::process_starttime(id.pid) == Some(id.starttime)) + } + None => (false, false), + }; + + // socket 存活守卫:存活 server 的处置归 `claim_sidecar` 裁决(同版本 + // AlreadyRunning 拒绝、异版本 VersionMismatch 走交接)。自动扫描绝不杀 + // 存活 server:`decide` 表的 Kill 行人工清理语义,杀存活 server 等于抢占, + // 违反单例交接协议。 + if socket_alive { + return LivenessOutcome::Noop; + } + + // 按裁决表分派。 + match cleanup::decide(identity.as_ref(), socket_exists, socket_alive, pid_alive, starttime_match) { + Decision::None => LivenessOutcome::Noop, + Decision::DeleteOnly => { + cleanup::remove_files(sidecar_path, socket_path); + LivenessOutcome::Cleaned + } + Decision::Kill => { + // Kill 决策必带可解析 sidecar(裁决表的两行 Kill 都在可解析分支), + // 否则 pid 不可得,退回无操作。 + let Some(id) = identity else { + return LivenessOutcome::Noop; + }; + kill_zombie(&id, sidecar_path, socket_path, zombie_exit_window, kill_wait) + } + Decision::Refuse => LivenessOutcome::Noop, + } +} + +/// 僵尸分支:有界等待 sidecar 消失,仍在则杀进程后删文件。 +/// +/// # 步骤 +/// 1. 有界等待 sidecar 消失(`zombie_exit_window`、50 毫秒轮询):覆盖旧 +/// server 正在退出序列中的窗口(排水安全超时 5 秒,sidecar 必在窗内消失)。 +/// 消失即无操作,`claim_sidecar` 的 O_EXCL 创建成功。 +/// 2. 窗口后 sidecar 仍在:真僵尸(进程活着且不在退出序列,socket 被外部 +/// 删除)。发 SIGTERM 并有界等待退出(`kill_wait`)。 +/// 3. 进程退出后删文件(尽力而为;旧 server 的退出序列自己删文件,此处兜底)。 +/// 进程在窗口内未退出(杀进程失败):不触碰任何文件,返回 `KillFailed`。 +/// +/// # 边界与前提 +/// - 杀进程唯一前置是刻度匹配(由调用方经 `decide` 保证):PID 复用必失配。 +/// +/// # 副作用 +/// - 可能向 sidecar 记录的进程发 SIGTERM;可能删除 socket 与 sidecar 文件。 +/// - 杀进程决策与失败记入跨重启保留的日志文件。 +fn kill_zombie( + id: &sidecar::ServerIdentity, sidecar_path: &Path, socket_path: &Path, zombie_exit_window: Duration, kill_wait: Duration, +) -> LivenessOutcome { + // 有界等待 sidecar 消失:覆盖旧 server 退出序列窗口(排水 5 秒,sidecar + // 最后删除)。消失即无操作,`claim_sidecar` 的 O_EXCL 创建成功。 + let deadline = Instant::now() + zombie_exit_window; + while sidecar_path.exists() { + if Instant::now() >= deadline { + break; + } + std::thread::sleep(ZOMBIE_POLL_INTERVAL); + } + if !sidecar_path.exists() { + return LivenessOutcome::Noop; + } + + // 窗口后 sidecar 仍在:真僵尸(进程活着且不在退出序列,socket 被外部删除)。 + // 发 SIGTERM 并有界等待退出。 + oxide_log::__log_event!( + "oxide::kernel", + oxide_log::tracing::Level::INFO, + "启动 liveness 扫描:发现僵尸 server(进程号 {}),发送 SIGTERM", + id.pid + ); + if !cleanup::send_sigterm(id.pid) || !cleanup::wait_pid_exit(id.pid, kill_wait) { + // 杀进程失败:不触碰任何文件,保守拒绝并指向人工清理命令。 + oxide_log::__log_event!( + "oxide::kernel", + oxide_log::tracing::Level::WARN, + "启动 liveness 扫描:僵尸 server(进程号 {})杀进程失败,文件未触碰,可用 `oxide server cleanup` 人工清理", + id.pid + ); + return LivenessOutcome::KillFailed; + } + + // 进程已退出:删文件(尽力而为;旧 server 的退出序列自己删文件,此处兜底)。 + cleanup::remove_files(sidecar_path, socket_path); + oxide_log::__log_event!( + "oxide::kernel", + oxide_log::tracing::Level::INFO, + "启动 liveness 扫描:僵尸 server(进程号 {})已清理,文件已删", + id.pid + ); + LivenessOutcome::Cleaned +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::os::unix::net::UnixListener; + use std::path::PathBuf; + use std::process::Command; + use std::time::{SystemTime, UNIX_EPOCH}; + + /// temp_dir 下的唯一临时目录,退出时自动删除。 + struct TestDir(PathBuf); + + impl TestDir { + fn new() -> Self { + let ns = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("oxide_liveness_test_{}_{}", std::process::id(), ns)); + fs::create_dir_all(&dir).expect("测试目录创建失败"); + TestDir(dir) + } + + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + /// 保证不存在的 pid:pid_max 之上永远不会被分配。 + fn dead_pid() -> u32 { + let pid_max = fs::read_to_string("/proc/sys/kernel/pid_max") + .expect("读取 pid_max 失败") + .trim() + .parse::() + .expect("解析 pid_max 失败"); + pid_max + 1 + } + + /// 测试身份:pid 与刻度可控,其余字段为占位值。 + fn test_identity(pid: u32, starttime: u64, socket_path: &Path) -> sidecar::ServerIdentity { + sidecar::ServerIdentity { + pid, + socket_path: socket_path.to_str().unwrap().to_string(), + version: "0.0.0".into(), + started_at: 0, + starttime, + } + } + + /// 无残留:两文件都不存在,判无操作。 + #[test] + fn no_residue_noop() { + let dir = TestDir::new(); + let outcome = liveness_scan(&dir.path("sidecar.json"), &dir.path("server.sock")); + assert_eq!(outcome, LivenessOutcome::Noop, "无文件应判无操作"); + } + + /// 孤儿 socket:无 sidecar、socket 文件存在但无监听者,socket 文件被删。 + #[test] + fn orphan_socket_removed() { + let dir = TestDir::new(); + let sidecar = dir.path("sidecar.json"); + let socket = dir.path("server.sock"); + fs::write(&socket, b"stale").expect("写陈旧 socket 文件应成功"); + let outcome = liveness_scan(&sidecar, &socket); + assert_eq!(outcome, LivenessOutcome::Cleaned, "孤儿 socket 文件应判已清理"); + assert!(!socket.exists(), "socket 文件应被删除"); + assert!(!sidecar.exists(), "sidecar 不应被创建"); + } + + /// 死 PID:sidecar 记死 PID、无 socket,两文件被删。 + #[test] + fn dead_pid_removed() { + let dir = TestDir::new(); + let sidecar = dir.path("sidecar.json"); + let socket = dir.path("server.sock"); + sidecar::write_exclusive(&test_identity(dead_pid(), 0, &socket), &sidecar).expect("写 sidecar 应成功"); + let outcome = liveness_scan(&sidecar, &socket); + assert_eq!(outcome, LivenessOutcome::Cleaned, "死 PID 残留应判已清理"); + assert!(!sidecar.exists(), "sidecar 应被删除"); + assert!(!socket.exists(), "socket 文件应保持不存在"); + } + + /// PID 复用:sidecar 记本进程 PID 加占位刻度(失配)、无 socket,文件被删, + /// 本进程不受影响(无杀进程调用)。 + #[test] + fn reused_pid_files_removed() { + let dir = TestDir::new(); + let sidecar = dir.path("sidecar.json"); + let socket = dir.path("server.sock"); + sidecar::write_exclusive(&test_identity(std::process::id(), 0, &socket), &sidecar).expect("写 sidecar 应成功"); + let outcome = liveness_scan(&sidecar, &socket); + assert_eq!(outcome, LivenessOutcome::Cleaned, "刻度失配残留应判已清理"); + assert!(!sidecar.exists(), "sidecar 应被删除"); + assert!(sidecar::pid_alive(std::process::id()), "本进程应不受影响"); + } + + /// 损坏 sidecar 加陈旧 socket 文件:两文件被删。 + #[test] + fn corrupted_sidecar_stale_socket_removed() { + let dir = TestDir::new(); + let sidecar = dir.path("sidecar.json"); + let socket = dir.path("server.sock"); + fs::write(&sidecar, "not json at all").expect("写损坏文件应成功"); + fs::write(&socket, b"stale").expect("写陈旧 socket 文件应成功"); + let outcome = liveness_scan(&sidecar, &socket); + assert_eq!(outcome, LivenessOutcome::Cleaned, "损坏 sidecar 加陈旧 socket 应判已清理"); + assert!(!sidecar.exists(), "sidecar 应被删除"); + assert!(!socket.exists(), "socket 文件应被删除"); + } + + /// socket 存活:绑定监听者加同版本 sidecar,无操作、两文件原样(自动场景 + /// 不杀存活 server)。 + #[test] + fn live_socket_noop() { + let dir = TestDir::new(); + let sidecar = dir.path("sidecar.json"); + let socket = dir.path("server.sock"); + let listener = UnixListener::bind(&socket).expect("绑定监听者应成功"); + sidecar::write_exclusive( + &sidecar::ServerIdentity::new(socket.to_str().unwrap(), "0.0.0"), + &sidecar, + ) + .expect("写 sidecar 应成功"); + let outcome = liveness_scan(&sidecar, &socket); + assert_eq!(outcome, LivenessOutcome::Noop, "存活 socket 应判无操作"); + assert!(sidecar.exists(), "sidecar 应原样"); + assert!(socket.exists(), "socket 文件应原样"); + drop(listener); + } + + /// 僵尸退出窗口:sidecar 记本进程 PID 加真实刻度、无 socket,并发线程在 + /// 等待窗口内删 sidecar,判无操作(不触发杀进程)。 + #[test] + fn zombie_exit_window_noop() { + let dir = TestDir::new(); + let sidecar = dir.path("sidecar.json"); + let socket = dir.path("server.sock"); + // sidecar 记本进程 PID 加真实刻度(进程活且刻度匹配)、无 socket。 + let id = sidecar::ServerIdentity::new(socket.to_str().unwrap(), "0.0.0"); + sidecar::write_exclusive(&id, &sidecar).expect("写 sidecar 应成功"); + // 扫描线程:sidecar 存在时进入僵尸分支,有界等待 sidecar 消失。 + let sidecar_scan = sidecar.clone(); + let socket_scan = socket.clone(); + let scan_handle = std::thread::spawn(move || { + liveness_scan_with_waits(&sidecar_scan, &socket_scan, Duration::from_secs(5), Duration::from_secs(1)) + }); + // 主线程在窗口内删 sidecar(模拟旧 server 退出序列),扫描应判无操作。 + std::thread::sleep(Duration::from_millis(200)); + let _ = fs::remove_file(&sidecar); + let outcome = scan_handle.join().expect("扫描线程应正常退出"); + assert_eq!(outcome, LivenessOutcome::Noop, "sidecar 在窗口内消失应判无操作"); + } + + /// 僵尸杀进程:真实子进程(sleep 30)加其实测刻度写 sidecar、无 socket, + /// 注入短超时,子进程被杀、文件被删,判已清理。 + #[test] + fn zombie_kill_process() { + let dir = TestDir::new(); + let sidecar = dir.path("sidecar.json"); + let socket = dir.path("server.sock"); + let mut child = Command::new("sleep").arg("30").spawn().expect("子进程启动应成功"); + let pid = child.id(); + // 测试进程是父进程:被杀的子进程在回收前是僵尸,kill -0 恒返回真, + // 需回收线程并发收尸,探活才会在窗口内转假。 + let reaper = std::thread::spawn(move || { + let _ = child.wait(); + }); + let id = test_identity(pid, sidecar::process_starttime(pid).expect("子进程启动刻度应可读"), &socket); + sidecar::write_exclusive(&id, &sidecar).expect("写 sidecar 应成功"); + // 注入短僵尸退出窗口(500 毫秒)加短杀进程等待(3 秒):子进程存活, + // sidecar 不消失,扫描等窗口后杀进程。 + let outcome = liveness_scan_with_waits(&sidecar, &socket, Duration::from_millis(500), Duration::from_secs(3)); + assert_eq!(outcome, LivenessOutcome::Cleaned, "刻度匹配的存活进程应被杀且文件被删"); + // 有界等待回收线程:子进程被杀后收尸即退出,不无限阻塞。 + let deadline = Instant::now() + Duration::from_secs(5); + while !reaper.is_finished() { + assert!(Instant::now() < deadline, "回收线程应有界退出"); + std::thread::sleep(Duration::from_millis(10)); + } + reaper.join().expect("回收线程应正常退出"); + assert!(!sidecar::pid_alive(pid), "子进程应已退出"); + assert!(!sidecar.exists(), "sidecar 应被删除"); + } + + /// 杀进程失败:子进程忽略 SIGTERM(sh trap)、注入短超时,判杀进程失败、 + /// 文件原样、子进程存活(测试收尾清理子进程)。 + #[test] + fn kill_failed_when_pid_ignores_term() { + let dir = TestDir::new(); + let sidecar = dir.path("sidecar.json"); + let socket = dir.path("server.sock"); + let mut child = Command::new("sh") + .args(["-c", "trap '' TERM; sleep 30"]) + .spawn() + .expect("子进程启动应成功"); + let pid = child.id(); + let id = test_identity(pid, sidecar::process_starttime(pid).expect("子进程启动刻度应可读"), &socket); + sidecar::write_exclusive(&id, &sidecar).expect("写 sidecar 应成功"); + // 注入短僵尸退出窗口(500 毫秒)加短杀进程等待(300 毫秒):子进程忽略 + // SIGTERM,扫描判杀进程失败,文件原样。 + let outcome = liveness_scan_with_waits(&sidecar, &socket, Duration::from_millis(500), Duration::from_millis(300)); + assert_eq!(outcome, LivenessOutcome::KillFailed, "忽略 SIGTERM 应判杀进程失败"); + assert!(sidecar.exists(), "sidecar 应原样"); + assert!(!socket.exists(), "socket 文件应保持不存在"); + assert!(sidecar::pid_alive(pid), "子进程应存活"); + let _ = child.kill(); + let _ = child.wait(); + } +} diff --git a/crates/oxide_cli/src/server/mod.rs b/crates/oxide_cli/src/server/mod.rs index 4950891e..b8ee91fe 100644 --- a/crates/oxide_cli/src/server/mod.rs +++ b/crates/oxide_cli/src/server/mod.rs @@ -2,8 +2,9 @@ //! //! 持久 server 是常驻进程,复用预热池、近零 spawn 成本; //! 通信走 Unix socket + NDJSON(换行分隔 JSON)。 -//! 模块分十层:协议数据面(帧格式与 serde 结构体)、身份注册(sidecar 文件 -//! 与 liveness 检查原语)、控制客户端(读 sidecar、连接 server、发控制请求 +//! 模块分十一层:协议数据面(帧格式与 serde 结构体)、身份注册(sidecar 文件 +//! 与 liveness 检查原语)、启动时 liveness 扫描(扫陈旧 sidecar 与 socket、 +//! 僵尸态自动恢复)、控制客户端(读 sidecar、连接 server、发控制请求 //! 帧、读回响应帧、三分支错误)、执行请求路径(parse → compile → spawn → //! run → format → drop 六段纯函数)、worker 池(固定 N 常驻 worker、各持 //! 自有 VM 池、mpsc 轮询路由)、server 进程主体(accept 循环、控制请求直接 @@ -22,6 +23,8 @@ pub mod eval; /// forge 状态查询:读四张共享 forge 条目数与容量,执行 gc / clear-cache / /// lookup 三旗标。 pub mod forge; +/// 启动时 liveness 扫描:扫陈旧 sidecar 与 socket,僵尸态自动恢复。 +pub mod liveness; /// 日志读取:行级别解析、阈值加最后 N 行过滤、整文件读取、阻塞式跟踪。 pub mod log; /// 协议数据面:NDJSON 帧格式与 serde 结构体。 diff --git a/crates/oxide_cli/src/server/server.rs b/crates/oxide_cli/src/server/server.rs index dc6b956a..85aa8101 100644 --- a/crates/oxide_cli/src/server/server.rs +++ b/crates/oxide_cli/src/server/server.rs @@ -33,6 +33,7 @@ use oxide_kernel::kernel::{KernelConfig, KernelCore}; use oxide_log::{Level, LogConfig, Output, SUBSYSTEM_COUNT}; use oxide_vm::vm_pool::PoolCounters; +use super::liveness; use super::protocol::{self, ForgeTarget, FrameReader, ProtocolError, ServerRequest, ServerResponse}; use super::sidecar::{self, ClaimResult}; use super::workers::{self, WorkerRouter}; @@ -148,21 +149,25 @@ struct ServerContext { socket_path: String, } -/// server 进程主体:认领 sidecar → 注册信号处理器 → 绑定 socket → -/// 初始化日志文件输出 → 建内核 → 派生 worker → accept 循环 → 在途归零 → -/// 清空发送端 → join worker → drop 内核 → 先删 socket 文件后删 sidecar。 +/// server 进程主体:初始化日志文件输出 → 启动时 liveness 扫描 → 认领 sidecar → +/// 注册信号处理器 → 绑定 socket → 建内核 → 派生 worker → accept 循环 → +/// 在途归零 → 清空发送端 → join worker → drop 内核 → 先删 socket 文件后删 +/// sidecar。 /// /// # 步骤 -/// 1. 认领 sidecar(启动顺序契约:先写 sidecar 再 bind socket);已有同版本 +/// 1. 初始化日志文件输出(sidecar 同主名的追加文件、Info 级;init 幂等, +/// 内核构造内部的重复调用是空操作)。守护形态 stderr 是 null,日志文件是 +/// 唯一的持久诊断通道,liveness 扫描的动作记入其中。 +/// 2. 启动时 liveness 扫描:认领裁决前清掉陈旧残留(僵尸态自动恢复);杀进程 +/// 失败映射 `ClaimRefused(RefusedAmbiguous)`,诊断指向人工清理命令。 +/// 3. 认领 sidecar(启动顺序契约:先写 sidecar 再 bind socket);已有同版本 /// 存活 server 时拒绝启动,版本不匹配时转入交接路径(向旧 server 发 /// yield 请求,等旧 server 排空退出后重新认领)。 -/// 2. 注册信号处理器(绑定 socket 之前;失败回滚 sidecar,此时无存活线程)。 -/// 3. 绑定监听器;失败且路径存在时删 stale socket 文件重试一次。 -/// 4. 初始化日志文件输出(sidecar 同主名的追加文件、Info 级;init 幂等, -/// 内核构造内部的重复调用是空操作)。 -/// 5. 建内核(标准配置)与共享聚合计数器。 -/// 6. 派生 N 个常驻 worker(各在自己线程上建自有池)。 -/// 7. 进入 accept 循环;关闭标志置位后按关闭序列退出。 +/// 4. 注册信号处理器(绑定 socket 之前;失败回滚 sidecar,此时无存活线程)。 +/// 5. 绑定监听器;失败且路径存在时删 stale socket 文件重试一次。 +/// 6. 建内核(标准配置)与共享聚合计数器。 +/// 7. 派生 N 个常驻 worker(各在自己线程上建自有池)。 +/// 8. 进入 accept 循环;关闭标志置位后按关闭序列退出。 /// /// # 边界与前提 /// - 认领得 `AlreadyRunning`(同版本)立即返回 `ClaimRefused`,不交接。 @@ -176,6 +181,8 @@ struct ServerContext { /// - 创建 sidecar 与 socket 文件;正常退出时删除两者。 /// - 创建日志文件(追加写、sidecar 同主名);正常退出时不删除(跨重启的 /// 诊断工件)。 +/// - 启动时 liveness 扫描可能向 sidecar 记录的进程发 SIGTERM、删除陈旧的 +/// socket 与 sidecar 文件(僵尸态自动恢复)。 /// - 注册进程级信号处理器(SIGINT、SIGTERM,termination 特性下含 SIGHUP)。 /// - 派生 N 个常驻 worker 线程与每连接一个 I/O 线程。 /// @@ -186,6 +193,26 @@ struct ServerContext { /// - 关闭序列固定:清空发送端 → join 全部 worker → drop 内核(内核 drop /// 的调试断言要求存活 VM 数为零,worker 池随线程退出先释放,前提满足)。 pub fn run_server(config: &ServerConfig) -> Result<(), ServerError> { + // 日志文件:sidecar 同主名的追加文件,最先初始化(init 幂等,内核构造 + // 内部的重复调用是空操作;顺序颠倒则输出回落环境变量路径,日志文件为 + // 空)。守护形态 stderr 是 null,日志文件是唯一的持久诊断通道,liveness + // 扫描的动作(杀进程决策与失败)记入其中。 + let log_path = config.sidecar_path.with_extension("log"); + oxide_log::init(&LogConfig { + output: Output::FileExact(log_path), + levels: [Level::Info; SUBSYSTEM_COUNT], + }); + + // 启动时 liveness 扫描:认领裁决前清掉陈旧残留(僵尸态自动恢复)。 + // 杀进程失败映射既有的 ClaimRefused(RefusedAmbiguous),诊断指向人工清理 + // 命令。 + match liveness::liveness_scan(&config.sidecar_path, &config.socket_path) { + liveness::LivenessOutcome::Noop | liveness::LivenessOutcome::Cleaned => {} + liveness::LivenessOutcome::KillFailed => { + return Err(ServerError::ClaimRefused(ClaimResult::RefusedAmbiguous)) + } + } + // 认领 sidecar:同版本拒绝启动,异版本走交接路径,存活证据不足即返回错误。 match sidecar::claim_sidecar(&config.sidecar_path, &config.socket_path, &config.version) { ClaimResult::Registered => {} @@ -206,15 +233,6 @@ pub fn run_server(config: &ServerConfig) -> Result<(), ServerError> { // 绑定监听器:stale socket 文件删一次重试,失败回滚 sidecar。 let listener = bind_listener(config)?; - // 日志文件:sidecar 同主名的追加文件,先于内核构造初始化(init 幂等, - // 内核构造内部的重复调用是空操作;顺序颠倒则输出回落环境变量路径, - // 日志文件为空)。 - let log_path = config.sidecar_path.with_extension("log"); - oxide_log::init(&LogConfig { - output: Output::FileExact(log_path), - levels: [Level::Info; SUBSYSTEM_COUNT], - }); - // 建内核(标准配置)与共享聚合计数器(全体 worker 池的增减累加到同一聚合)。 let kernel = KernelCore::new(KernelConfig::standard()); let counters = PoolCounters::shared(); @@ -1241,9 +1259,11 @@ mod tests { ) .expect("写模拟 sidecar 应成功"); - // 模拟连接处理:读一帧后回 Error 帧。 + // 模拟连接处理:接受循环,逐连接读一帧后回 Error 帧。须保持监听 + // (真实 server 是 accept 循环):启动时 liveness 扫描与认领探活各 + // 消耗一条探活连接,若只处理一条即退出会丢监听者,后续探活误判死。 std::thread::spawn(move || { - if let Ok((mut stream, _)) = listener.accept() { + while let Ok((mut stream, _)) = listener.accept() { let mut reader = FrameReader::new(io::BufReader::new(stream.try_clone().expect("克隆流应成功"))); let _ = reader.read_frame(); let frame = protocol::encode_response(&ServerResponse::Error {