diff --git a/Cargo.lock b/Cargo.lock index 78288280..2498d27c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1160,7 +1160,7 @@ dependencies = [ [[package]] name = "oxide_builtins" -version = "0.8.34" +version = "0.8.35" dependencies = [ "chrono", "fast-float", @@ -1185,7 +1185,7 @@ dependencies = [ [[package]] name = "oxide_bytecode" -version = "0.8.34" +version = "0.8.35" dependencies = [ "num-bigint", "oxide_log", @@ -1193,7 +1193,7 @@ dependencies = [ [[package]] name = "oxide_cfg" -version = "0.8.34" +version = "0.8.35" dependencies = [ "oxide_bytecode", "oxide_emit", @@ -1204,7 +1204,7 @@ dependencies = [ [[package]] name = "oxide_cli" -version = "0.8.34" +version = "0.8.35" dependencies = [ "ansi_term", "bumpalo", @@ -1225,7 +1225,7 @@ dependencies = [ [[package]] name = "oxide_code_cache" -version = "0.8.34" +version = "0.8.35" dependencies = [ "lru", "oxide_bytecode", @@ -1234,7 +1234,7 @@ dependencies = [ [[package]] name = "oxide_compiler" -version = "0.8.34" +version = "0.8.35" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1253,7 +1253,7 @@ dependencies = [ [[package]] name = "oxide_dce" -version = "0.8.34" +version = "0.8.35" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1267,7 +1267,7 @@ dependencies = [ [[package]] name = "oxide_emit" -version = "0.8.34" +version = "0.8.35" dependencies = [ "num-bigint", "oxc_allocator", @@ -1282,7 +1282,7 @@ dependencies = [ [[package]] name = "oxide_ir" -version = "0.8.34" +version = "0.8.35" dependencies = [ "oxide_bytecode", "oxide_log", @@ -1291,7 +1291,7 @@ dependencies = [ [[package]] name = "oxide_kernel" -version = "0.8.34" +version = "0.8.35" dependencies = [ "bumpalo", "dashmap", @@ -1306,7 +1306,7 @@ dependencies = [ [[package]] name = "oxide_liveness" -version = "0.8.34" +version = "0.8.35" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1318,7 +1318,7 @@ dependencies = [ [[package]] name = "oxide_log" -version = "0.8.34" +version = "0.8.35" dependencies = [ "tracing", "tracing-appender", @@ -1327,7 +1327,7 @@ dependencies = [ [[package]] name = "oxide_parser" -version = "0.8.34" +version = "0.8.35" dependencies = [ "oxc_allocator", "oxc_ast", @@ -1342,7 +1342,7 @@ dependencies = [ [[package]] name = "oxide_regalloc" -version = "0.8.34" +version = "0.8.35" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1358,7 +1358,7 @@ dependencies = [ [[package]] name = "oxide_runtime_api" -version = "0.8.34" +version = "0.8.35" dependencies = [ "num-bigint", "num-traits", @@ -1370,7 +1370,7 @@ dependencies = [ [[package]] name = "oxide_test262" -version = "0.8.34" +version = "0.8.35" dependencies = [ "oxide_bytecode", "oxide_compiler", @@ -1387,7 +1387,7 @@ dependencies = [ [[package]] name = "oxide_types" -version = "0.8.34" +version = "0.8.35" dependencies = [ "bumpalo", "hashbrown 0.14.5", @@ -1399,7 +1399,7 @@ dependencies = [ [[package]] name = "oxide_vm" -version = "0.8.34" +version = "0.8.35" dependencies = [ "bumpalo", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 4023f127..999b3221 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ members = [ ] [workspace.package] -version = "0.8.34" +version = "0.8.35" edition = "2021" license = "MIT" rust-version = "1.80" diff --git a/crates/oxide_cli/src/server/mod.rs b/crates/oxide_cli/src/server/mod.rs index 35247e39..d1b1d6a1 100644 --- a/crates/oxide_cli/src/server/mod.rs +++ b/crates/oxide_cli/src/server/mod.rs @@ -15,10 +15,11 @@ pub mod eval; /// 协议数据面:NDJSON 帧格式与 serde 结构体。 pub mod protocol; /// server 进程主体:accept 循环、控制请求分派、执行请求路由、信号处理 -/// (SIGINT/SIGTERM 置位关闭标志)、交接协议(向旧 server 发 yield 请求使其 -/// 排空退出、新 server 接管 socket 路径)、--rm 独立模式(进程唯一 socket -/// 路径、不注册 sidecar、不碰全局路径、单连接、空闲超时或断开即退出) -/// 与优雅退出。 +/// (SIGINT/SIGTERM 置位关闭标志)、版本管理(sidecar 记构建版本,启动时 +/// 与存活 server 版本比对:匹配不交接、不匹配强制交接)、交接协议(向旧 +/// server 发 yield 请求使其排空退出、新 server 接管 socket 路径)、--rm +/// 独立模式(进程唯一 socket 路径、不注册 sidecar、不碰全局路径、单连接、 +/// 空闲超时或断开即退出)与优雅退出。 #[allow(clippy::module_inception)] pub mod server; /// 身份注册:sidecar 文件、liveness 检查与陈旧清理。 diff --git a/crates/oxide_cli/src/server/server.rs b/crates/oxide_cli/src/server/server.rs index 278fb5e0..a3c401af 100644 --- a/crates/oxide_cli/src/server/server.rs +++ b/crates/oxide_cli/src/server/server.rs @@ -39,7 +39,7 @@ use super::workers::{self, WorkerRouter}; /// 信号处理器注册失败、交接失败。 #[derive(Debug, Clone, PartialEq, Eq)] pub enum ServerError { - /// sidecar 认领未通过(已有存活 server 或存活证据不足以裁决)。 + /// sidecar 认领未通过(已有同版本存活 server 或存活证据不足以裁决)。 ClaimRefused(ClaimResult), /// socket 绑定失败(含删除 stale socket 文件重试一次后仍失败)。 BindFailed(String), @@ -80,15 +80,19 @@ pub struct ServerConfig { pub sidecar_path: PathBuf, /// 常驻 worker 线程数;缺省取宿主核数,下限钳制为 1。 pub worker_count: usize, + /// 本进程构建版本,版本比对与 sidecar 写入用;生产默认取构建期版本。 + pub version: String, } impl ServerConfig { - /// 生产默认配置:socket 与 sidecar 取 well-known 位置,worker 数取宿主核数。 + /// 生产默认配置:socket 与 sidecar 取 well-known 位置,worker 数取宿主 + /// 核数,版本取构建期版本。 pub fn well_known() -> Self { ServerConfig { socket_path: sidecar::well_known_socket_path(), sidecar_path: sidecar::well_known_sidecar_path(), worker_count: default_worker_count(), + version: env!("CARGO_PKG_VERSION").to_string(), } } } @@ -147,9 +151,9 @@ struct ServerContext { /// drop 内核 → 先删 socket 文件后删 sidecar。 /// /// # 步骤 -/// 1. 认领 sidecar(启动顺序契约:先写 sidecar 再 bind socket);已有存活 -/// server 时转入交接路径(向旧 server 发 yield 请求,等旧 server 排空 -/// 退出后重新认领)。 +/// 1. 认领 sidecar(启动顺序契约:先写 sidecar 再 bind socket);已有同版本 +/// 存活 server 时拒绝启动,版本不匹配时转入交接路径(向旧 server 发 +/// yield 请求,等旧 server 排空退出后重新认领)。 /// 2. 注册信号处理器(绑定 socket 之前;失败回滚 sidecar,此时无存活线程)。 /// 3. 绑定监听器;失败且路径存在时删 stale socket 文件重试一次。 /// 4. 建内核(标准配置)与共享聚合计数器。 @@ -157,7 +161,9 @@ struct ServerContext { /// 6. 进入 accept 循环;关闭标志置位后按关闭序列退出。 /// /// # 边界与前提 -/// - 认领得 `AlreadyRunning` 时走交接路径,交接失败以 `YieldFailed` 返回。 +/// - 认领得 `AlreadyRunning`(同版本)立即返回 `ClaimRefused`,不交接。 +/// - 认领得 `VersionMismatch`(异版本)走交接路径,交接失败以 `YieldFailed` +/// 返回。 /// - 认领得 `RefusedAmbiguous` 立即返回错误,不双注册。 /// - 信号处理器注册失败(系统级错误)回滚 sidecar 并以错误返回。 /// - stale socket 文件删除重试后再失败时回滚删除 sidecar 并以错误返回。 @@ -174,10 +180,11 @@ struct ServerContext { /// - 关闭序列固定:清空发送端 → join 全部 worker → drop 内核(内核 drop /// 的调试断言要求存活 VM 数为零,worker 池随线程退出先释放,前提满足)。 pub fn run_server(config: &ServerConfig) -> Result<(), ServerError> { - // 认领 sidecar:已有存活 server 走交接路径,存活证据不足即返回错误。 - match sidecar::claim_sidecar(&config.sidecar_path, &config.socket_path) { + // 认领 sidecar:同版本拒绝启动,异版本走交接路径,存活证据不足即返回错误。 + match sidecar::claim_sidecar(&config.sidecar_path, &config.socket_path, &config.version) { ClaimResult::Registered => {} - ClaimResult::AlreadyRunning => yield_and_takeover(config)?, + ClaimResult::AlreadyRunning => return Err(ServerError::ClaimRefused(ClaimResult::AlreadyRunning)), + ClaimResult::VersionMismatch => yield_and_takeover(config)?, ClaimResult::RefusedAmbiguous => return Err(ServerError::ClaimRefused(ClaimResult::RefusedAmbiguous)), } @@ -421,8 +428,9 @@ fn finish_shutdown( /// - 失败时不触碰旧 server 的文件(socket 与 sidecar),人工兜底归清理命令。 /// /// # 注意事项 -/// - 纯函数式入口:版本管理路径在版本不匹配强制交接时调用同一入口。 -fn yield_and_takeover(config: &ServerConfig) -> Result<(), ServerError> { +/// - 纯函数式入口:版本管理路径(版本不匹配强制交接)与显式交接路径调用 +/// 同一入口。 +pub fn yield_and_takeover(config: &ServerConfig) -> Result<(), ServerError> { // 连接旧 server:连接成功是交接的前提。 let mut stream = UnixStream::connect(&config.socket_path) .map_err(|e| ServerError::YieldFailed(format!("连接旧 server 失败:{e}")))?; @@ -457,7 +465,7 @@ fn yield_and_takeover(config: &ServerConfig) -> Result<(), ServerError> { } // 重新认领:sidecar 已消失,O_EXCL 创建应成功;其余态判交接失败。 - match sidecar::claim_sidecar(&config.sidecar_path, &config.socket_path) { + match sidecar::claim_sidecar(&config.sidecar_path, &config.socket_path, &config.version) { ClaimResult::Registered => Ok(()), other => Err(ServerError::YieldFailed(format!("交接后重新认领未通过:{other:?}"))), } @@ -724,6 +732,7 @@ mod tests { socket_path: dir.join("server.sock"), sidecar_path: dir.join("sidecar.json"), worker_count: 2, + version: env!("CARGO_PKG_VERSION").to_string(), } } @@ -938,10 +947,11 @@ mod tests { stop_server(&mut stream, handle); } - /// 同一连接连续两帧(状态后版本)逐帧应答;同路径第二实例走交接路径接管 - /// (第一 server 正常退出,第二实例接管 socket 路径)。 + /// 同一连接连续两帧(状态后版本)逐帧应答;同路径同版本第二实例拒绝启动 + /// (认领判 AlreadyRunning,run_server 返回 ClaimRefused),第一 server + /// 继续正常应答且文件原样。 #[test] - fn multi_frame_same_connection_and_second_instance_takeover() { + fn multi_frame_same_connection_and_second_instance_refused() { let config = unique_paths("multi"); let handle = start_server(config.clone()); let mut stream = connect(&config); @@ -952,33 +962,28 @@ mod tests { let second = send_and_recv(&mut stream, &ServerRequest::Version); assert!(matches!(second, ServerResponse::Version { .. }), "应得 Version 帧:{second:?}"); - // 关闭测试连接,让第一 server 的在途排水快速归零。 - drop(stream); - - // 同路径第二实例:sidecar 已存在且 socket 存活,走交接路径接管。 + // 同路径同版本第二实例:认领判 AlreadyRunning,run_server 在有界时间内 + // 返回 ClaimRefused。 let config_b = config.clone(); let handle_b = std::thread::spawn(move || run_server(&config_b)); - - // 第一 server 应在有界时间内正常退出(yield 受理后走既有退出序列)。 let deadline = Instant::now() + Duration::from_secs(30); - while !handle.is_finished() { - assert!(Instant::now() < deadline, "第一 server 应在 30 秒内退出"); + while !handle_b.is_finished() { + assert!(Instant::now() < deadline, "第二实例应在 30 秒内返回"); std::thread::sleep(Duration::from_millis(10)); } - let result = handle.join().expect("第一 server 线程应正常退出"); - assert!(result.is_ok(), "第一 server 交接后应正常退出:{result:?}"); + let result_b = handle_b.join().expect("第二实例线程应正常退出"); + assert!( + matches!(result_b, Err(ServerError::ClaimRefused(ClaimResult::AlreadyRunning))), + "同版本第二实例应拒绝启动:{result_b:?}" + ); - // 第二实例已接管:等 socket 文件出现(第二实例的绑定),核对 sidecar 身份。 - let deadline = Instant::now() + Duration::from_secs(30); - while !config.socket_path.exists() { - assert!(Instant::now() < deadline, "第二实例的 socket 文件应出现"); - std::thread::sleep(Duration::from_millis(10)); - } - let id = sidecar::read_identity(&config.sidecar_path).expect("第二实例的 sidecar 应可读"); - assert_eq!(id.pid, std::process::id(), "第二实例的 sidecar 应为本进程身份"); + // 第一 server 继续正常应答,文件原样。 + let response = send_and_recv(&mut stream, &ServerRequest::Version); + assert!(matches!(response, ServerResponse::Version { .. }), "第一 server 应继续应答:{response:?}"); + assert!(config.sidecar_path.exists(), "第一 server 的 sidecar 应原样"); + assert!(config.socket_path.exists(), "第一 server 的 socket 应原样"); - let mut stream_b = connect(&config); - stop_server(&mut stream_b, handle_b); + stop_server(&mut stream, handle); } /// 端到端并发:两条连接各发 3 个 eval,6 个响应帧全部正确且逐连接顺序与请求一致。 @@ -1129,16 +1134,19 @@ mod tests { assert!(!config.socket_path.exists(), "socket 文件应被删除"); } - /// 交接端到端:server A 运行中,server B 以同配置启动经 yield 路径接管; - /// A 正常退出,B 的 sidecar 为本进程身份(O_EXCL 重新认领通过即证明 A 的 - /// 文件已删除),B 可正常应答版本请求,随后以 Shutdown 停 B。 + /// 交接端到端:server A(版本 0.0.1)运行中,server B(版本 0.0.2)以同 + /// 路径异版本启动经 yield 路径接管;A 正常退出,B 的 sidecar 为本进程 + /// 身份且版本 0.0.2(O_EXCL 重新认领通过即证明 A 的文件已删除),B 可 + /// 正常应答版本请求,随后以 Shutdown 停 B。 #[test] fn yield_takeover_end_to_end() { let config = unique_paths("yield_e2e"); - let handle_a = start_server(config.clone()); + // A 用版本 0.0.1。 + let config_a = ServerConfig { version: "0.0.1".to_string(), ..config.clone() }; + let handle_a = start_server(config_a); - // B 以同配置启动:认领得 AlreadyRunning 转交接路径。 - let config_b = config.clone(); + // B 用版本 0.0.2:认领得 VersionMismatch 转交接路径。 + let config_b = ServerConfig { version: "0.0.2".to_string(), ..config.clone() }; let handle_b = std::thread::spawn(move || run_server(&config_b)); // A 应在 30 秒内有界退出(排水加退出序列)。 @@ -1158,6 +1166,7 @@ mod tests { } let id = sidecar::read_identity(&config.sidecar_path).expect("B 的 sidecar 应可读"); assert_eq!(id.pid, std::process::id(), "B 的 sidecar 应为本进程身份"); + assert_eq!(id.version, "0.0.2", "B 的 sidecar 版本应为 0.0.2"); // B 正常应答版本请求。 let mut stream = connect(&config); @@ -1166,18 +1175,20 @@ mod tests { stop_server(&mut stream, handle_b); } - /// 旧 server 不受理交接:裸监听者加手写 sidecar 模拟旧 server, + /// 旧 server 不受理交接:裸监听者加手写 sidecar 模拟旧 server(sidecar + /// 版本 0.0.0 与本进程版本不同,认领经 VersionMismatch 走交接路径), /// 接受连接后回 Error 帧(模拟旧版本 binary 无 yield 变体); /// run_server 判 YieldFailed 且不触碰旧 server 的文件。 #[test] fn yield_failed_when_old_server_refuses() { let config = unique_paths("yield_refused"); - // 模拟旧 server:绑定 socket 并写本进程身份 sidecar(PID 存活且启动 - // 时刻刻度匹配,认领判 AlreadyRunning)。 + // 模拟旧 server:绑定 socket 并写本进程身份加异版本(0.0.0)的 + // sidecar(PID 存活且启动时刻刻度匹配,版本不匹配,认领判 + // VersionMismatch)。 let listener = UnixListener::bind(&config.socket_path).expect("绑定模拟旧 server 应成功"); sidecar::write_exclusive( - &sidecar::ServerIdentity::new(config.socket_path.to_str().unwrap()), + &sidecar::ServerIdentity::new(config.socket_path.to_str().unwrap(), "0.0.0"), &config.sidecar_path, ) .expect("写模拟 sidecar 应成功"); @@ -1208,7 +1219,7 @@ mod tests { let listener = UnixListener::bind(&config.socket_path).expect("绑定模拟旧 server 应成功"); sidecar::write_exclusive( - &sidecar::ServerIdentity::new(config.socket_path.to_str().unwrap()), + &sidecar::ServerIdentity::new(config.socket_path.to_str().unwrap(), "0.0.0"), &config.sidecar_path, ) .expect("写模拟 sidecar 应成功"); diff --git a/crates/oxide_cli/src/server/sidecar.rs b/crates/oxide_cli/src/server/sidecar.rs index 9378af80..1c013a94 100644 --- a/crates/oxide_cli/src/server/sidecar.rs +++ b/crates/oxide_cli/src/server/sidecar.rs @@ -40,15 +40,17 @@ impl ServerIdentity { /// 构造当前进程的身份(pid、版本、启动时间、启动时刻刻度)。 /// /// # 边界与前提 + /// - 版本由调用方注入(生产默认取构建期版本,测试注入可区分值), + /// 本函数不读取构建期版本。 /// - 非 Linux 宿主(`/proc` 缺位)时 `starttime` 填 0,交叉核对退化, /// liveness 由 socket 探活裁决。 - pub fn new(socket_path: impl AsRef) -> Self { + pub fn new(socket_path: impl AsRef, version: &str) -> Self { let pid = std::process::id(); let started_at = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); ServerIdentity { pid, socket_path: socket_path.as_ref().to_string_lossy().into_owned(), - version: env!("CARGO_PKG_VERSION").to_string(), + version: version.to_string(), started_at, starttime: process_starttime(pid).unwrap_or(0), } @@ -144,13 +146,15 @@ pub fn process_starttime(pid: u32) -> Option { rest.split_whitespace().nth(19)?.parse().ok() } -/// 认领结果:注册入口的三态。 +/// 认领结果:注册入口的四态。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ClaimResult { /// 新注册成功(sidecar 已写入)。 Registered, - /// 已有存活 server,拒绝注册。 + /// 已有同版本存活 server,拒绝注册(旧 server 继续运行)。 AlreadyRunning, + /// 已有存活 server 且版本与本进程不同(含旧 sidecar 不可读),调用方应强制交接。 + VersionMismatch, /// 存活证据不足以裁决(进程存活但 socket 不在),保守拒绝并提示跑清理命令。 RefusedAmbiguous, } @@ -159,7 +163,9 @@ pub enum ClaimResult { /// /// # 步骤 /// 1. 尝试 O_EXCL 创建;sidecar 不存在即成功。 -/// 2. sidecar 已存在:探活 socket,连接成功即有存活 server,拒绝。 +/// 2. sidecar 已存在:探活 socket,连接成功即有存活 server,读回旧 sidecar +/// 与本进程版本比对:匹配判 `AlreadyRunning`,不匹配或不可读判 +/// `VersionMismatch`。 /// 3. socket 死:按 sidecar 可读性分支。 /// - 可解析且 PID 死:陈旧残留,删 sidecar 与 socket 文件后重建。 /// - 可解析且 PID 活:交叉核对启动时刻刻度,匹配则保守拒绝(server @@ -168,6 +174,9 @@ pub enum ClaimResult { /// /// # 边界与前提 /// - 启动顺序契约是「先写 sidecar 再 bind socket」,存活 server 必有 sidecar。 +/// - 版本比对是精确字符串相等(同一性判定,不是新旧次序判定);旧 sidecar +/// 不可读(损坏、缺 version 字段)按不匹配处理,方向安全(强制交接失败 +/// 不触碰旧 server 的文件,旧 server 继续运行)。 /// /// # 副作用 /// - 可能创建、删除、重写 sidecar 与 socket 文件。 @@ -175,9 +184,9 @@ pub enum ClaimResult { /// # 注意事项 /// - 意外 io 错误(权限等)保守判 `RefusedAmbiguous`;方向安全(拒绝注册 /// 不会双注册)。 -pub fn claim_sidecar(sidecar_path: &Path, socket_path: &Path) -> ClaimResult { +pub fn claim_sidecar(sidecar_path: &Path, socket_path: &Path, new_version: &str) -> ClaimResult { // 先尝试 O_EXCL 创建:sidecar 不存在即成功。 - match write_exclusive(&ServerIdentity::new(socket_path), sidecar_path) { + match write_exclusive(&ServerIdentity::new(socket_path, new_version), sidecar_path) { Ok(()) => return ClaimResult::Registered, Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {} Err(_) => return ClaimResult::RefusedAmbiguous, @@ -185,27 +194,32 @@ pub fn claim_sidecar(sidecar_path: &Path, socket_path: &Path) -> ClaimResult { // socket 探活是存活的唯一事实源:连接成功即有存活 server。 if is_server_alive(socket_path) { - return ClaimResult::AlreadyRunning; + // 读回旧 sidecar 与本进程版本比对:匹配拒绝启动,不匹配或不可读 + // 交调用方强制交接。 + match read_identity(sidecar_path) { + Some(id) if id.version == new_version => return ClaimResult::AlreadyRunning, + _ => return ClaimResult::VersionMismatch, + } } // socket 死:按 sidecar 可读性分支。 match read_identity(sidecar_path) { - Some(id) => claim_dead_socket(&id, sidecar_path, socket_path), + Some(id) => claim_dead_socket(&id, sidecar_path, socket_path, new_version), None => { // 损坏文件:删 sidecar 与 socket 文件后重建(socket 探活已失败, // 文件无监听者,不会误删存活 server 的 socket)。 remove_stale(sidecar_path, socket_path); - re_register(sidecar_path, socket_path) + re_register(sidecar_path, socket_path, new_version) } } } /// socket 死且 sidecar 可解析:按 PID 与启动时刻刻度裁决陈旧性。 -fn claim_dead_socket(id: &ServerIdentity, sidecar_path: &Path, socket_path: &Path) -> ClaimResult { +fn claim_dead_socket(id: &ServerIdentity, sidecar_path: &Path, socket_path: &Path, new_version: &str) -> ClaimResult { // PID 死:陈旧残留,删 sidecar 与 socket 文件后重建。 if !pid_alive(id.pid) { remove_stale(sidecar_path, socket_path); - return re_register(sidecar_path, socket_path); + return re_register(sidecar_path, socket_path, new_version); } // PID 活:交叉核对启动时刻刻度裁决 PID 复用。 @@ -216,7 +230,7 @@ fn claim_dead_socket(id: &ServerIdentity, sidecar_path: &Path, socket_path: &Pat // 刻度失配:PID 已被复用,sidecar 是陈旧残留。 remove_stale(sidecar_path, socket_path); - re_register(sidecar_path, socket_path) + re_register(sidecar_path, socket_path, new_version) } /// 删除 sidecar 与 socket 文件(socket 探活已失败,文件无监听者,不会误删 @@ -227,8 +241,8 @@ fn remove_stale(sidecar_path: &Path, socket_path: &Path) { } /// 清理后重建 sidecar,重建失败保守拒绝。 -fn re_register(sidecar_path: &Path, socket_path: &Path) -> ClaimResult { - match write_exclusive(&ServerIdentity::new(socket_path), sidecar_path) { +fn re_register(sidecar_path: &Path, socket_path: &Path, new_version: &str) -> ClaimResult { + match write_exclusive(&ServerIdentity::new(socket_path, new_version), sidecar_path) { Ok(()) => ClaimResult::Registered, Err(_) => ClaimResult::RefusedAmbiguous, } @@ -301,7 +315,7 @@ mod tests { fn identity_roundtrip() { let dir = TestDir::new(); let path = dir.path("sidecar.json"); - let id = ServerIdentity::new("/tmp/oxide-test.sock"); + let id = ServerIdentity::new("/tmp/oxide-test.sock", "0.0.0"); write_exclusive(&id, &path).expect("写 sidecar 应成功"); let read = read_identity(&path).expect("读回 sidecar 应成功"); assert_eq!(read, id, "五字段应逐字段一致"); @@ -312,7 +326,7 @@ mod tests { fn write_exclusive_second_create_fails() { let dir = TestDir::new(); let path = dir.path("sidecar.json"); - let id = ServerIdentity::new("/tmp/oxide-test.sock"); + let id = ServerIdentity::new("/tmp/oxide-test.sock", "0.0.0"); write_exclusive(&id, &path).expect("首次写入应成功"); let err = write_exclusive(&id, &path).unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::AlreadyExists, "二次 create_new 必得 AlreadyExists"); @@ -324,22 +338,37 @@ mod tests { let dir = TestDir::new(); let sidecar = dir.path("sidecar.json"); let socket = dir.path("server.sock"); - assert_eq!(claim_sidecar(&sidecar, &socket), ClaimResult::Registered); + assert_eq!(claim_sidecar(&sidecar, &socket, "0.0.0"), ClaimResult::Registered); let id = read_identity(&sidecar).expect("sidecar 应已写入"); assert_eq!(id.pid, std::process::id()); assert_eq!(id.socket_path, socket.to_str().unwrap()); } - /// 认领分支:存活 server(真实监听者绑 socket 路径)拒绝注册。 + /// 认领分支:存活监听者加同版本 sidecar 判 AlreadyRunning(旧 server 继续 + /// 运行,新 server 拒绝启动)。 #[test] - fn claim_live_server_refused() { + fn claim_live_server_same_version_refused() { let dir = TestDir::new(); let sidecar = dir.path("sidecar.json"); let socket = dir.path("server.sock"); // 存活 server 必有 sidecar(启动顺序契约:先写 sidecar 再 bind socket)。 let listener = UnixListener::bind(&socket).expect("绑定监听者应成功"); - write_exclusive(&ServerIdentity::new(socket.to_str().unwrap()), &sidecar).expect("写 sidecar 应成功"); - assert_eq!(claim_sidecar(&sidecar, &socket), ClaimResult::AlreadyRunning); + write_exclusive(&ServerIdentity::new(socket.to_str().unwrap(), "0.0.0"), &sidecar).expect("写 sidecar 应成功"); + assert_eq!(claim_sidecar(&sidecar, &socket, "0.0.0"), ClaimResult::AlreadyRunning); + drop(listener); + } + + /// 认领分支:存活监听者加异版本 sidecar 判 VersionMismatch(调用方应强制 + /// 交接)。 + #[test] + fn claim_live_server_version_mismatch() { + 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(&ServerIdentity::new(socket.to_str().unwrap(), "0.0.0"), &sidecar).expect("写 sidecar 应成功"); + assert_eq!(claim_sidecar(&sidecar, &socket, "0.0.1"), ClaimResult::VersionMismatch); drop(listener); } @@ -350,7 +379,7 @@ mod tests { let sidecar = dir.path("sidecar.json"); let socket = dir.path("server.sock"); write_exclusive(&stale_identity(dead_pid(), &socket), &sidecar).expect("写陈旧 sidecar 应成功"); - assert_eq!(claim_sidecar(&sidecar, &socket), ClaimResult::Registered); + assert_eq!(claim_sidecar(&sidecar, &socket, "0.0.0"), ClaimResult::Registered); let id = read_identity(&sidecar).expect("重建 sidecar 应可读"); assert_eq!(id.pid, std::process::id(), "重建 sidecar 应为本进程身份"); } @@ -363,7 +392,7 @@ mod tests { let socket = dir.path("server.sock"); // PID 是本进程(存活),启动时刻刻度为占位值:PID 已复用。 write_exclusive(&stale_identity(std::process::id(), &socket), &sidecar).expect("写陈旧 sidecar 应成功"); - assert_eq!(claim_sidecar(&sidecar, &socket), ClaimResult::Registered); + assert_eq!(claim_sidecar(&sidecar, &socket, "0.0.0"), ClaimResult::Registered); } /// 认领分支:存活 PID 加匹配启动时刻保守拒绝。 @@ -373,9 +402,9 @@ mod tests { let sidecar = dir.path("sidecar.json"); let socket = dir.path("server.sock"); // PID 与启动时刻刻度都是本进程的:进程活且刻度匹配。 - let id = ServerIdentity::new(socket.to_str().unwrap()); + let id = ServerIdentity::new(socket.to_str().unwrap(), "0.0.0"); write_exclusive(&id, &sidecar).expect("写 sidecar 应成功"); - assert_eq!(claim_sidecar(&sidecar, &socket), ClaimResult::RefusedAmbiguous); + assert_eq!(claim_sidecar(&sidecar, &socket, "0.0.0"), ClaimResult::RefusedAmbiguous); } /// 认领分支:损坏文件加死 socket 判陈旧,sidecar 重建。 @@ -386,7 +415,7 @@ mod tests { let socket = dir.path("server.sock"); // 损坏文件:半写 JSON。 fs::write(&sidecar, r#"{"pid":1,"socket_path":""#).expect("写损坏文件应成功"); - assert_eq!(claim_sidecar(&sidecar, &socket), ClaimResult::Registered); + assert_eq!(claim_sidecar(&sidecar, &socket, "0.0.0"), ClaimResult::Registered); let id = read_identity(&sidecar).expect("重建 sidecar 应可读"); assert_eq!(id.pid, std::process::id()); } @@ -401,21 +430,22 @@ mod tests { fs::write(&sidecar, "not json at all").expect("写损坏文件应成功"); // 陈旧 socket 文件:文件存在但无监听者。 fs::write(&socket, b"stale").expect("写陈旧 socket 文件应成功"); - assert_eq!(claim_sidecar(&sidecar, &socket), ClaimResult::Registered); + assert_eq!(claim_sidecar(&sidecar, &socket, "0.0.0"), ClaimResult::Registered); let id = read_identity(&sidecar).expect("重建 sidecar 应可读"); assert_eq!(id.pid, std::process::id(), "重建 sidecar 应为本进程身份"); assert!(!socket.exists(), "陈旧 socket 文件应被删除"); } - /// 认领分支:损坏文件加存活 socket 拒绝(socket 是存活事实源)。 + /// 认领分支:损坏文件加存活 socket 判 VersionMismatch(不可读按不匹配 + /// 处理,调用方应强制交接,socket 是存活事实源)。 #[test] - fn claim_corrupted_sidecar_live_socket_refused() { + fn claim_corrupted_sidecar_live_socket_mismatch() { let dir = TestDir::new(); let sidecar = dir.path("sidecar.json"); let socket = dir.path("server.sock"); let listener = UnixListener::bind(&socket).expect("绑定监听者应成功"); fs::write(&sidecar, "not json at all").expect("写损坏文件应成功"); - assert_eq!(claim_sidecar(&sidecar, &socket), ClaimResult::AlreadyRunning); + assert_eq!(claim_sidecar(&sidecar, &socket, "0.0.0"), ClaimResult::VersionMismatch); drop(listener); } diff --git a/crates/oxide_cli/tests/server_signal_int.rs b/crates/oxide_cli/tests/server_signal_int.rs index 729a53ab..199653ae 100644 --- a/crates/oxide_cli/tests/server_signal_int.rs +++ b/crates/oxide_cli/tests/server_signal_int.rs @@ -23,6 +23,7 @@ fn unique_paths(test_name: &str) -> ServerConfig { socket_path: dir.join("server.sock"), sidecar_path: dir.join("sidecar.json"), worker_count: 2, + version: env!("CARGO_PKG_VERSION").to_string(), } } diff --git a/crates/oxide_cli/tests/server_signal_term.rs b/crates/oxide_cli/tests/server_signal_term.rs index fd18d0a4..df9a49a1 100644 --- a/crates/oxide_cli/tests/server_signal_term.rs +++ b/crates/oxide_cli/tests/server_signal_term.rs @@ -23,6 +23,7 @@ fn unique_paths(test_name: &str) -> ServerConfig { socket_path: dir.join("server.sock"), sidecar_path: dir.join("sidecar.json"), worker_count: 2, + version: env!("CARGO_PKG_VERSION").to_string(), } }