From be565bb945b52c82257f8d5fb3e6ac797c0cce30 Mon Sep 17 00:00:00 2001 From: pjh456 <147148383@qq.com> Date: Sun, 11 Oct 2026 13:34:45 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20server=20start=20=E6=8E=A5=E9=80=9A?= =?UTF-8?q?=E7=AB=AF=E5=8F=A3=E6=97=97=E6=A0=87=E4=B8=8E=20well-known=20TC?= =?UTF-8?q?P=20=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 40 +++--- Cargo.toml | 2 +- crates/oxide_cli/src/main.rs | 24 +++- crates/oxide_cli/src/server/cleanup.rs | 90 +++++++------ crates/oxide_cli/src/server/client.rs | 9 +- crates/oxide_cli/src/server/liveness.rs | 87 +++++++------ crates/oxide_cli/src/server/server.rs | 103 ++++++--------- crates/oxide_cli/src/server/sidecar.rs | 162 ++++++++++++++++-------- crates/oxide_cli/src/server/spawn.rs | 10 +- crates/oxide_cli/src/server/watchdog.rs | 7 +- crates/oxide_cli/tests/server_cli.rs | 75 ++++++----- crates/oxide_cli/tests/server_client.rs | 64 ++++++++++ 12 files changed, 408 insertions(+), 265 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 07877f4e..e001a94b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1160,7 +1160,7 @@ dependencies = [ [[package]] name = "oxide_builtins" -version = "0.15.6" +version = "0.15.7" dependencies = [ "chrono", "fast-float", @@ -1185,7 +1185,7 @@ dependencies = [ [[package]] name = "oxide_bytecode" -version = "0.15.6" +version = "0.15.7" dependencies = [ "num-bigint", "oxide_log", @@ -1193,7 +1193,7 @@ dependencies = [ [[package]] name = "oxide_cfg" -version = "0.15.6" +version = "0.15.7" dependencies = [ "oxide_bytecode", "oxide_emit", @@ -1204,7 +1204,7 @@ dependencies = [ [[package]] name = "oxide_cli" -version = "0.15.6" +version = "0.15.7" dependencies = [ "ansi_term", "bumpalo", @@ -1228,7 +1228,7 @@ dependencies = [ [[package]] name = "oxide_code_cache" -version = "0.15.6" +version = "0.15.7" dependencies = [ "lru", "oxide_bytecode", @@ -1237,7 +1237,7 @@ dependencies = [ [[package]] name = "oxide_compiler" -version = "0.15.6" +version = "0.15.7" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1257,7 +1257,7 @@ dependencies = [ [[package]] name = "oxide_dce" -version = "0.15.6" +version = "0.15.7" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1271,7 +1271,7 @@ dependencies = [ [[package]] name = "oxide_emit" -version = "0.15.6" +version = "0.15.7" dependencies = [ "num-bigint", "oxc_allocator", @@ -1286,7 +1286,7 @@ dependencies = [ [[package]] name = "oxide_engine" -version = "0.15.6" +version = "0.15.7" dependencies = [ "oxide_bytecode", "oxide_compiler", @@ -1298,7 +1298,7 @@ dependencies = [ [[package]] name = "oxide_ir" -version = "0.15.6" +version = "0.15.7" dependencies = [ "oxide_bytecode", "oxide_log", @@ -1307,7 +1307,7 @@ dependencies = [ [[package]] name = "oxide_kernel" -version = "0.15.6" +version = "0.15.7" dependencies = [ "bumpalo", "dashmap", @@ -1321,7 +1321,7 @@ dependencies = [ [[package]] name = "oxide_liveness" -version = "0.15.6" +version = "0.15.7" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1333,7 +1333,7 @@ dependencies = [ [[package]] name = "oxide_log" -version = "0.15.6" +version = "0.15.7" dependencies = [ "tracing", "tracing-appender", @@ -1342,11 +1342,11 @@ dependencies = [ [[package]] name = "oxide_net" -version = "0.15.6" +version = "0.15.7" [[package]] name = "oxide_parser" -version = "0.15.6" +version = "0.15.7" dependencies = [ "oxc_allocator", "oxc_ast", @@ -1361,7 +1361,7 @@ dependencies = [ [[package]] name = "oxide_regalloc" -version = "0.15.6" +version = "0.15.7" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1377,7 +1377,7 @@ dependencies = [ [[package]] name = "oxide_runtime_api" -version = "0.15.6" +version = "0.15.7" dependencies = [ "num-bigint", "num-traits", @@ -1390,7 +1390,7 @@ dependencies = [ [[package]] name = "oxide_test262" -version = "0.15.6" +version = "0.15.7" dependencies = [ "oxide_bytecode", "oxide_compiler", @@ -1408,7 +1408,7 @@ dependencies = [ [[package]] name = "oxide_types" -version = "0.15.6" +version = "0.15.7" dependencies = [ "bumpalo", "dashmap", @@ -1421,7 +1421,7 @@ dependencies = [ [[package]] name = "oxide_vm" -version = "0.15.6" +version = "0.15.7" dependencies = [ "bumpalo", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 200f6025..fa181a75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ members = [ ] [workspace.package] -version = "0.15.6" +version = "0.15.7" edition = "2021" license = "MIT" rust-version = "1.80" diff --git a/crates/oxide_cli/src/main.rs b/crates/oxide_cli/src/main.rs index 22d00561..ca846285 100644 --- a/crates/oxide_cli/src/main.rs +++ b/crates/oxide_cli/src/main.rs @@ -2,12 +2,14 @@ #![allow(dead_code)] use std::fs; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::process::ExitCode; use std::time::{Duration, Instant}; use ansi_term::Colour::Red; use clap::{Parser, Subcommand}; use oxide_cli::format_js_value; +use oxide_net::Endpoint; use oxide_cli::server::cleanup::{well_known_cleanup, CleanupOutcome}; use oxide_cli::server::client::send_control_request; use oxide_cli::server::log as server_log; @@ -123,6 +125,9 @@ enum ServerCommands { /// 常驻 worker 线程数;缺省取宿主核数。 #[arg(long)] workers: Option, + /// TCP 监听端口;缺省取 well-known 端口。 + #[arg(long, default_value_t = sidecar::WELL_KNOWN_PORT, conflicts_with = "rm")] + port: u16, /// 前台形态:当前进程直接运行持久 server(守护形态 spawn 的子进程用, /// 不向用户暴露)。 #[arg(long, hide = true)] @@ -324,6 +329,7 @@ fn server_command(command: ServerCommands) -> ExitCode { rm, idle_timeout, workers, + port, foreground, } => { if rm { @@ -336,13 +342,16 @@ fn server_command(command: ServerCommands) -> ExitCode { } start_result(run_server_rm(&config)) } else if foreground { - let mut config = ServerConfig::well_known(); + let mut config = ServerConfig { + endpoint: Endpoint::Tcp(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port)), + ..ServerConfig::well_known() + }; if let Some(n) = workers { config.worker_count = n as usize; } start_result(run_server(&config)) } else { - start_daemon(workers) + start_daemon(workers, port) } } ServerCommands::Cleanup => match well_known_cleanup() { @@ -382,7 +391,7 @@ fn server_command(command: ServerCommands) -> ExitCode { }), ServerCommands::Info => control_arm(&ServerRequest::Info, |response| match response { ServerResponse::Info { version, socket_path, pid } => { - Some(format!("版本:{version}\nsocket:{socket_path}\n进程号:{pid}")) + Some(format!("版本:{version}\nendpoint:{socket_path}\n进程号:{pid}")) } _ => None, }), @@ -491,9 +500,9 @@ fn control_arm(request: &ServerRequest, render: fn(&ServerResponse) -> Option) -> ExitCode { +fn start_daemon(workers: Option, port: u16) -> ExitCode { // spawn:脱离子进程,标准流全空,不等待。 - if let Err(err) = spawn::spawn_detached_server(workers) { + if let Err(err) = spawn::spawn_detached_server(workers, port) { eprintln!("{}", Red.paint(format!("spawn server 进程失败:{err}"))); return ExitCode::FAILURE; } @@ -545,8 +554,9 @@ fn restart_command() -> ExitCode { std::thread::sleep(Duration::from_millis(50)); } - // 拉起新 server:脱离进程,标准流全空,不等待。 - if let Err(err) = spawn::spawn_detached_server(None) { + // 拉起新 server:脱离进程,标准流全空,不等待。新 server 按缺省配置拉起 + // (well-known 端口,不继承旧旗标)。 + if let Err(err) = spawn::spawn_detached_server(None, sidecar::WELL_KNOWN_PORT) { eprintln!("{}", Red.paint(format!("拉起新 server 失败:{err}"))); return ExitCode::FAILURE; } diff --git a/crates/oxide_cli/src/server/cleanup.rs b/crates/oxide_cli/src/server/cleanup.rs index b7e16350..95ed5c2f 100644 --- a/crates/oxide_cli/src/server/cleanup.rs +++ b/crates/oxide_cli/src/server/cleanup.rs @@ -113,7 +113,7 @@ pub fn decide( /// 清理入口:扫 sidecar、探活、杀 PID、删孤儿文件。 /// /// # 步骤 -/// 1. 采集五个探活信号(sidecar 可解析性、socket 存活、socket 文件存在、 +/// 1. 采集五个探活信号(sidecar 可解析性、endpoint 存活、sidecar 文件存在、 /// PID 存活、刻度匹配)。 /// 2. 调 `decide` 得决策。 /// 3. 按决策落地(无操作 / 只删文件 / 先杀后删 / 拒绝)。 @@ -125,22 +125,24 @@ pub fn decide( /// - 可能向 sidecar 记录的进程发 SIGTERM;可能删除 socket 与 sidecar 文件。 /// /// # 注意事项 -/// - 两条保守拒绝底线:socket 存活但 PID 无法可靠确定、杀进程超时,均不 +/// - 两条保守拒绝底线:endpoint 存活但 PID 无法可靠确定、杀进程超时,均不 /// 触碰文件。 -pub fn cleanup(sidecar_path: &Path, socket_path: &Path) -> CleanupOutcome { - cleanup_with_wait(sidecar_path, socket_path, DEFAULT_KILL_WAIT) +pub fn cleanup(sidecar_path: &Path, endpoint: &Endpoint) -> CleanupOutcome { + cleanup_with_wait(sidecar_path, endpoint, DEFAULT_KILL_WAIT) } /// 清理入口(可注入杀进程等待,测试注入短超时)。 /// /// # 步骤 -/// 1. 采集信号:`read_identity`(缺失与损坏同归 `None`)、socket 文件存在、 -/// `is_server_alive`(仅文件存在时探活)、`pid_alive`、`process_starttime` -/// 与 sidecar 记录值比对(PID 死时匹配位恒假)。 +/// 1. 采集信号:`read_identity`(缺失与损坏同归 `None`)、sidecar 文件存在、 +/// `is_server_alive`(探 sidecar 记录的 endpoint,不可读时探调用方 +/// endpoint 兜底)、`pid_alive`、`process_starttime` 与 sidecar 记录值比对 +/// (PID 死时匹配位恒假)。 /// 2. 调 `decide` 得决策。 -/// 3. 按决策落地:无操作返回 `Clean`;只删文件返回 `Removed`;先杀后删在 -/// 信号发送失败或等待超时时返回 `KillFailed`(文件不碰)、成功返回 -/// `Removed`;拒绝返回 `RefusedLiveServer`(文件不碰)。 +/// 3. 按决策落地:无操作返回 `Clean`(Unix 臂 sidecar 缺失而 socket 文件存在时 +/// 按孤儿删除返回 `Removed`);只删文件返回 `Removed`;先杀后删在信号发送 +/// 失败或等待超时时返回 `KillFailed`(文件不碰)、成功返回 `Removed`;拒绝 +/// 返回 `RefusedLiveServer`(文件不碰)。 /// /// # 边界与前提 /// - `kill_wait` 是 SIGTERM 送达后等进程退出的窗口;server 走信号处理的 @@ -148,12 +150,17 @@ pub fn cleanup(sidecar_path: &Path, socket_path: &Path) -> CleanupOutcome { /// /// # 副作用 /// - 可能向 sidecar 记录的进程发 SIGTERM;可能删除 socket 与 sidecar 文件。 -pub fn cleanup_with_wait(sidecar_path: &Path, socket_path: &Path, kill_wait: Duration) -> CleanupOutcome { - // 采集信号:sidecar 可解析性(缺失与损坏同归 None)、socket 存活、 - // PID 存活与刻度匹配(PID 死时匹配位恒假)。 +pub fn cleanup_with_wait(sidecar_path: &Path, endpoint: &Endpoint, kill_wait: Duration) -> CleanupOutcome { + // 采集信号:sidecar 可解析性(缺失与损坏同归 None)、endpoint 存活、 + // PID 存活与刻度匹配(PID 死时匹配位恒假)。endpoint 探活目标取 sidecar + // 记录的 endpoint(正确性红线),不可读时取调用方 endpoint(防御性兜底)。 let identity = sidecar::read_identity(sidecar_path); - let socket_exists = socket_path.exists(); - let socket_alive = socket_exists && sidecar::is_server_alive(&Endpoint::Unix(socket_path.to_path_buf())); + let registry_exists = sidecar_path.exists(); + let probe = match &identity { + Some(id) => sidecar::parse_endpoint(&id.socket_path), + None => endpoint.clone(), + }; + let endpoint_alive = sidecar::is_server_alive(&probe); let (pid_alive, starttime_match) = match &identity { Some(id) => { let alive = sidecar::pid_alive(id.pid); @@ -163,13 +170,22 @@ pub fn cleanup_with_wait(sidecar_path: &Path, socket_path: &Path, kill_wait: Dur }; // 裁决:五个信号映射到四态决策。 - let decision = decide(identity.as_ref(), socket_exists, socket_alive, pid_alive, starttime_match); + let decision = decide(identity.as_ref(), registry_exists, endpoint_alive, pid_alive, starttime_match); // 按决策落地。 match decision { - Decision::None => CleanupOutcome::Clean, + Decision::None => { + // 遗留信号:Unix 臂 sidecar 缺失而 socket 文件存在,按孤儿删除。 + if let Some(path) = endpoint.socket_path() { + if !registry_exists && path.exists() { + let _ = fs::remove_file(path); + return CleanupOutcome::Removed; + } + } + CleanupOutcome::Clean + } Decision::DeleteOnly => { - remove_files(sidecar_path, socket_path); + remove_files(sidecar_path, endpoint); CleanupOutcome::Removed } Decision::Kill => { @@ -183,20 +199,19 @@ pub fn cleanup_with_wait(sidecar_path: &Path, socket_path: &Path, kill_wait: Dur return CleanupOutcome::KillFailed; } // server 自身退出序列已删文件时删除报错被忽略(尽力而为的残留兜底)。 - remove_files(sidecar_path, socket_path); + remove_files(sidecar_path, endpoint); CleanupOutcome::Removed } Decision::Refuse => CleanupOutcome::RefusedLiveServer, } } -/// well-known 路径清理入口:解析 well-known sidecar 与 socket 路径后调 -/// `cleanup`。 +/// well-known 路径清理入口:解析 well-known sidecar 与 endpoint 后调 `cleanup`。 /// /// # 注意事项 /// - CLI 分派臂与启动 liveness 扫描的稳定入口。 pub fn well_known_cleanup() -> CleanupOutcome { - cleanup(&sidecar::well_known_sidecar_path(), &sidecar::well_known_socket_path()) + cleanup(&sidecar::well_known_sidecar_path(), &sidecar::well_known_endpoint()) } /// 发送 SIGTERM:`kill -TERM`,与 `pid_alive` 的 `kill -0` 同一 `Command` 路径。 @@ -227,13 +242,16 @@ pub fn wait_pid_exit(pid: u32, timeout: Duration) -> bool { } /// 删除残留文件:socket 先、sidecar 后(与交接退出序列同一定序:新 server -/// 以 sidecar 消失为接管信号,届时 socket 文件必已删除)。 +/// 以 sidecar 消失为接管信号,届时 socket 文件必已删除)。TCP 臂无文件可删, +/// 只删 sidecar。 /// /// # 副作用 -/// - 可能删除 socket 文件与 sidecar 文件;文件已不存在不视为错误(server -/// 自身退出序列可能已删过)。 -pub fn remove_files(sidecar_path: &Path, socket_path: &Path) { - let _ = fs::remove_file(socket_path); +/// - 可能删除 socket 文件(Unix 臂)与 sidecar 文件;文件已不存在不视为 +/// 错误(server 自身退出序列可能已删过)。 +pub fn remove_files(sidecar_path: &Path, endpoint: &Endpoint) { + if let Some(path) = endpoint.socket_path() { + let _ = fs::remove_file(path); + } let _ = fs::remove_file(sidecar_path); } @@ -338,7 +356,7 @@ mod tests { #[test] fn cleanup_no_files_clean() { let dir = TestDir::new(); - let outcome = cleanup(&dir.path("sidecar.json"), &dir.path("server.sock")); + let outcome = cleanup(&dir.path("sidecar.json"), &Endpoint::Unix(dir.path("server.sock"))); assert_eq!(outcome, CleanupOutcome::Clean, "无文件应判无残留"); } @@ -349,7 +367,7 @@ mod tests { let sidecar = dir.path("sidecar.json"); let socket = dir.path("server.sock"); fs::write(&socket, b"stale").expect("写陈旧 socket 文件应成功"); - let outcome = cleanup(&sidecar, &socket); + let outcome = cleanup(&sidecar, &Endpoint::Unix(socket.clone())); assert_eq!(outcome, CleanupOutcome::Removed, "孤儿 socket 文件应判已清理"); assert!(!socket.exists(), "socket 文件应被删除"); assert!(!sidecar.exists(), "sidecar 不应被创建"); @@ -362,7 +380,7 @@ mod tests { 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 = cleanup(&sidecar, &socket); + let outcome = cleanup(&sidecar, &Endpoint::Unix(socket.clone())); assert_eq!(outcome, CleanupOutcome::Removed, "死 PID 残留应判已清理"); assert!(!sidecar.exists(), "sidecar 应被删除"); } @@ -375,7 +393,7 @@ mod tests { let socket = dir.path("server.sock"); fs::write(&sidecar, "not json at all").expect("写损坏文件应成功"); fs::write(&socket, b"stale").expect("写陈旧 socket 文件应成功"); - let outcome = cleanup(&sidecar, &socket); + let outcome = cleanup(&sidecar, &Endpoint::Unix(socket.clone())); assert_eq!(outcome, CleanupOutcome::Removed, "损坏 sidecar 加陈旧 socket 应判已清理"); assert!(!sidecar.exists(), "sidecar 应被删除"); assert!(!socket.exists(), "socket 文件应被删除"); @@ -389,7 +407,7 @@ mod tests { let socket = dir.path("server.sock"); let listener = UnixListener::bind(&socket).expect("绑定监听者应成功"); fs::write(&sidecar, "not json at all").expect("写损坏文件应成功"); - let outcome = cleanup(&sidecar, &socket); + let outcome = cleanup(&sidecar, &Endpoint::Unix(socket.clone())); assert_eq!(outcome, CleanupOutcome::RefusedLiveServer, "损坏 sidecar 加存活 socket 应判拒绝"); assert!(sidecar.exists(), "sidecar 应原样"); assert!(socket.exists(), "socket 文件应原样"); @@ -404,7 +422,7 @@ mod tests { 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 = cleanup(&sidecar, &socket); + let outcome = cleanup(&sidecar, &Endpoint::Unix(socket.clone())); assert_eq!(outcome, CleanupOutcome::Removed, "刻度失配残留应判已清理"); assert!(!sidecar.exists(), "sidecar 应被删除"); assert!(sidecar::pid_alive(std::process::id()), "本进程应不受影响"); @@ -419,7 +437,7 @@ mod tests { let socket = dir.path("server.sock"); let listener = UnixListener::bind(&socket).expect("绑定监听者应成功"); sidecar::write_exclusive(&test_identity(std::process::id(), 0, &socket), &sidecar).expect("写 sidecar 应成功"); - let outcome = cleanup(&sidecar, &socket); + let outcome = cleanup(&sidecar, &Endpoint::Unix(socket.clone())); assert_eq!(outcome, CleanupOutcome::RefusedLiveServer, "存活 socket 加刻度失配应判拒绝"); assert!(sidecar.exists(), "sidecar 应原样"); assert!(socket.exists(), "socket 文件应原样"); @@ -442,7 +460,7 @@ mod tests { }); let id = test_identity(pid, sidecar::process_starttime(pid).expect("子进程启动刻度应可读"), &socket); sidecar::write_exclusive(&id, &sidecar).expect("写 sidecar 应成功"); - let outcome = cleanup_with_wait(&sidecar, &socket, Duration::from_secs(3)); + let outcome = cleanup_with_wait(&sidecar, &Endpoint::Unix(socket.clone()), Duration::from_secs(3)); assert_eq!(outcome, CleanupOutcome::Removed, "刻度匹配的存活进程应被杀且文件被删"); // 有界等待回收线程:子进程被杀后收尸即退出,不无限阻塞。 let deadline = Instant::now() + Duration::from_secs(5); @@ -469,7 +487,7 @@ mod tests { let pid = child.id(); let id = test_identity(pid, sidecar::process_starttime(pid).expect("子进程启动刻度应可读"), &socket); sidecar::write_exclusive(&id, &sidecar).expect("写 sidecar 应成功"); - let outcome = cleanup_with_wait(&sidecar, &socket, Duration::from_millis(300)); + let outcome = cleanup_with_wait(&sidecar, &Endpoint::Unix(socket.clone()), Duration::from_millis(300)); assert_eq!(outcome, CleanupOutcome::KillFailed, "忽略 SIGTERM 应判杀进程失败"); assert!(sidecar.exists(), "sidecar 应原样"); assert!(!socket.exists(), "socket 文件应保持不存在"); diff --git a/crates/oxide_cli/src/server/client.rs b/crates/oxide_cli/src/server/client.rs index 80e002d8..ae445a55 100644 --- a/crates/oxide_cli/src/server/client.rs +++ b/crates/oxide_cli/src/server/client.rs @@ -11,7 +11,7 @@ use std::io::BufReader; use std::net::TcpStream; use std::os::unix::net::UnixStream; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::mpsc::RecvTimeoutError; use std::time::Duration; @@ -78,8 +78,9 @@ pub fn send_control_request_from(sidecar_path: &Path, request: &ServerRequest) - )) })?; - // 连接:5 秒超时是安全网,本地 socket 连接立即完成或立即失败。 - let mut stream = connect_with_timeout(&Endpoint::Unix(PathBuf::from(identity.socket_path)), CONNECT_TIMEOUT)?; + // 连接:5 秒超时是安全网,本地连接立即完成或立即失败。endpoint 取 sidecar + // 记录的文本形态(host:port 或路径),撤掉 Unix 过渡包装。 + let mut stream = connect_with_timeout(&sidecar::parse_endpoint(&identity.socket_path), CONNECT_TIMEOUT)?; // 读写超时各 5 秒:server 挂起(不回应)时不无限阻塞。 let _ = stream.set_read_timeout(Some(CONNECT_TIMEOUT)); @@ -181,7 +182,7 @@ mod tests { /// 写一个指向给定 socket 路径的合法 sidecar。 fn write_sidecar(sidecar: &Path, socket: &Path) { - let identity = sidecar::ServerIdentity::new(socket.to_str().unwrap(), "0.0.0"); + let identity = sidecar::ServerIdentity::new(&Endpoint::Unix(socket.to_path_buf()), "0.0.0"); sidecar::write_exclusive(&identity, sidecar).expect("写 sidecar 应成功"); } diff --git a/crates/oxide_cli/src/server/liveness.rs b/crates/oxide_cli/src/server/liveness.rs index 9df37b28..262edb80 100644 --- a/crates/oxide_cli/src/server/liveness.rs +++ b/crates/oxide_cli/src/server/liveness.rs @@ -44,16 +44,16 @@ pub enum LivenessOutcome { KillFailed, } -/// 启动时 liveness 扫描入口:扫 sidecar 与 socket,清理陈旧残留,僵尸态自动恢复。 +/// 启动时 liveness 扫描入口:扫 sidecar 与 endpoint,清理陈旧残留,僵尸态自动恢复。 /// /// # 步骤 -/// 1. 采集五个探活信号(sidecar 可解析性、socket 存活、socket 文件存在、 +/// 1. 采集五个探活信号(sidecar 可解析性、endpoint 存活、sidecar 文件存在、 /// PID 存活、刻度匹配)。 -/// 2. socket 存活守卫:存活即无操作(自动扫描绝不杀存活 server)。 +/// 2. endpoint 存活守卫:存活即无操作(自动扫描绝不杀存活 server)。 /// 3. 按 `decide` 裁决表分派:无操作 / 只删文件 / 僵尸分支 / 拒绝。 /// /// # 边界与前提 -/// - 只删 `config` 注入的 sidecar 与 socket 两条路径;日志文件不碰。 +/// - 只删 `config` 注入的 sidecar 与 socket 两条路径(Unix 臂);日志文件不碰。 /// - 杀进程等待取缺省 10 秒(长于 server 的在途排水安全超时 5 秒)。 /// /// # 副作用 @@ -61,20 +61,21 @@ pub enum LivenessOutcome { /// /// # 注意事项 /// - 须在 `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) +pub fn liveness_scan(sidecar_path: &Path, endpoint: &Endpoint) -> LivenessOutcome { + liveness_scan_with_waits(sidecar_path, endpoint, ZOMBIE_EXIT_WINDOW, cleanup::DEFAULT_KILL_WAIT) } /// 启动时 liveness 扫描(可注入两个超时,测试注入短窗口)。 /// /// # 步骤 -/// 1. 采集信号:`read_identity`(缺失与损坏同归 `None`)、socket 文件存在、 -/// `is_server_alive`(仅文件存在时探活)、`pid_alive`、`process_starttime` -/// 与 sidecar 记录值比对(PID 死时匹配位恒假)。 -/// 2. socket 存活守卫:存活即无操作。 +/// 1. 采集信号:`read_identity`(缺失与损坏同归 `None`)、sidecar 文件存在、 +/// `is_server_alive`(探 sidecar 记录的 endpoint,不可读时探调用方 +/// endpoint 兜底)、`pid_alive`、`process_starttime` 与 sidecar 记录值比对 +/// (PID 死时匹配位恒假)。 +/// 2. endpoint 存活守卫:存活即无操作。 /// 3. 按 `decide` 裁决表分派: -/// - `None` 无操作。 -/// - `DeleteOnly` 删文件(socket 先、sidecar 后)。 +/// - `None` 无操作(Unix 臂 sidecar 缺失而 socket 文件存在时按孤儿删除)。 +/// - `DeleteOnly` 删文件(socket 先、sidecar 后,Unix 臂)。 /// - `Kill` 僵尸分支:有界等待 sidecar 消失,仍在则杀进程后删文件。 /// - `Refuse` 无操作(文件不碰,归 `claim_sidecar` 裁决)。 /// @@ -88,13 +89,18 @@ pub fn liveness_scan(sidecar_path: &Path, socket_path: &Path) -> LivenessOutcome /// - 可能向 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, + sidecar_path: &Path, endpoint: &Endpoint, zombie_exit_window: Duration, kill_wait: Duration, ) -> LivenessOutcome { - // 采集信号:sidecar 可解析性(缺失与损坏同归 None)、socket 存活、 - // PID 存活与刻度匹配(PID 死时匹配位恒假)。 + // 采集信号:sidecar 可解析性(缺失与损坏同归 None)、endpoint 存活、 + // PID 存活与刻度匹配(PID 死时匹配位恒假)。endpoint 探活目标取 sidecar + // 记录的 endpoint(正确性红线),不可读时取调用方 endpoint(防御性兜底)。 let identity = sidecar::read_identity(sidecar_path); - let socket_exists = socket_path.exists(); - let socket_alive = socket_exists && sidecar::is_server_alive(&Endpoint::Unix(socket_path.to_path_buf())); + let registry_exists = sidecar_path.exists(); + let probe = match &identity { + Some(id) => sidecar::parse_endpoint(&id.socket_path), + None => endpoint.clone(), + }; + let endpoint_alive = sidecar::is_server_alive(&probe); let (pid_alive, starttime_match) = match &identity { Some(id) => { let alive = sidecar::pid_alive(id.pid); @@ -103,19 +109,28 @@ pub fn liveness_scan_with_waits( None => (false, false), }; - // socket 存活守卫:存活 server 的处置归 `claim_sidecar` 裁决(同版本 + // endpoint 存活守卫:存活 server 的处置归 `claim_sidecar` 裁决(同版本 // AlreadyRunning 拒绝、异版本 VersionMismatch 走交接)。自动扫描绝不杀 // 存活 server:`decide` 表的 Kill 行人工清理语义,杀存活 server 等于抢占, // 违反单例交接协议。 - if socket_alive { + if endpoint_alive { return LivenessOutcome::Noop; } // 按裁决表分派。 - match cleanup::decide(identity.as_ref(), socket_exists, socket_alive, pid_alive, starttime_match) { - Decision::None => LivenessOutcome::Noop, + match cleanup::decide(identity.as_ref(), registry_exists, endpoint_alive, pid_alive, starttime_match) { + Decision::None => { + // 遗留信号:Unix 臂 sidecar 缺失而 socket 文件存在,按孤儿删除。 + if let Some(path) = endpoint.socket_path() { + if !registry_exists && path.exists() { + let _ = std::fs::remove_file(path); + return LivenessOutcome::Cleaned; + } + } + LivenessOutcome::Noop + } Decision::DeleteOnly => { - cleanup::remove_files(sidecar_path, socket_path); + cleanup::remove_files(sidecar_path, endpoint); LivenessOutcome::Cleaned } Decision::Kill => { @@ -124,7 +139,7 @@ pub fn liveness_scan_with_waits( let Some(id) = identity else { return LivenessOutcome::Noop; }; - kill_zombie(&id, sidecar_path, socket_path, zombie_exit_window, kill_wait) + kill_zombie(&id, sidecar_path, endpoint, zombie_exit_window, kill_wait) } Decision::Refuse => LivenessOutcome::Noop, } @@ -148,7 +163,7 @@ pub fn liveness_scan_with_waits( /// - 可能向 sidecar 记录的进程发 SIGTERM;可能删除 socket 与 sidecar 文件。 /// - 杀进程决策与失败记入跨重启保留的日志文件。 fn kill_zombie( - id: &sidecar::ServerIdentity, sidecar_path: &Path, socket_path: &Path, zombie_exit_window: Duration, + id: &sidecar::ServerIdentity, sidecar_path: &Path, endpoint: &Endpoint, zombie_exit_window: Duration, kill_wait: Duration, ) -> LivenessOutcome { // 有界等待 sidecar 消失:覆盖旧 server 退出序列窗口(排水 5 秒,sidecar @@ -184,7 +199,7 @@ fn kill_zombie( } // 进程已退出:删文件(尽力而为;旧 server 的退出序列自己删文件,此处兜底)。 - cleanup::remove_files(sidecar_path, socket_path); + cleanup::remove_files(sidecar_path, endpoint); oxide_log::__log_event!( "oxide::kernel", oxide_log::tracing::Level::INFO, @@ -250,7 +265,7 @@ mod tests { #[test] fn no_residue_noop() { let dir = TestDir::new(); - let outcome = liveness_scan(&dir.path("sidecar.json"), &dir.path("server.sock")); + let outcome = liveness_scan(&dir.path("sidecar.json"), &Endpoint::Unix(dir.path("server.sock"))); assert_eq!(outcome, LivenessOutcome::Noop, "无文件应判无操作"); } @@ -261,7 +276,7 @@ mod tests { 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); + let outcome = liveness_scan(&sidecar, &Endpoint::Unix(socket.clone())); assert_eq!(outcome, LivenessOutcome::Cleaned, "孤儿 socket 文件应判已清理"); assert!(!socket.exists(), "socket 文件应被删除"); assert!(!sidecar.exists(), "sidecar 不应被创建"); @@ -274,7 +289,7 @@ mod tests { 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); + let outcome = liveness_scan(&sidecar, &Endpoint::Unix(socket.clone())); assert_eq!(outcome, LivenessOutcome::Cleaned, "死 PID 残留应判已清理"); assert!(!sidecar.exists(), "sidecar 应被删除"); assert!(!socket.exists(), "socket 文件应保持不存在"); @@ -288,7 +303,7 @@ mod tests { 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); + let outcome = liveness_scan(&sidecar, &Endpoint::Unix(socket.clone())); assert_eq!(outcome, LivenessOutcome::Cleaned, "刻度失配残留应判已清理"); assert!(!sidecar.exists(), "sidecar 应被删除"); assert!(sidecar::pid_alive(std::process::id()), "本进程应不受影响"); @@ -302,7 +317,7 @@ mod tests { 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); + let outcome = liveness_scan(&sidecar, &Endpoint::Unix(socket.clone())); assert_eq!(outcome, LivenessOutcome::Cleaned, "损坏 sidecar 加陈旧 socket 应判已清理"); assert!(!sidecar.exists(), "sidecar 应被删除"); assert!(!socket.exists(), "socket 文件应被删除"); @@ -316,9 +331,9 @@ mod tests { 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) + sidecar::write_exclusive(&sidecar::ServerIdentity::new(&Endpoint::Unix(socket.clone()), "0.0.0"), &sidecar) .expect("写 sidecar 应成功"); - let outcome = liveness_scan(&sidecar, &socket); + let outcome = liveness_scan(&sidecar, &Endpoint::Unix(socket.clone())); assert_eq!(outcome, LivenessOutcome::Noop, "存活 socket 应判无操作"); assert!(sidecar.exists(), "sidecar 应原样"); assert!(socket.exists(), "socket 文件应原样"); @@ -333,13 +348,13 @@ mod tests { 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"); + let id = sidecar::ServerIdentity::new(&Endpoint::Unix(socket.clone()), "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)) + liveness_scan_with_waits(&sidecar_scan, &Endpoint::Unix(socket_scan), Duration::from_secs(5), Duration::from_secs(1)) }); // 主线程在窗口内删 sidecar(模拟旧 server 退出序列),扫描应判无操作。 std::thread::sleep(Duration::from_millis(200)); @@ -366,7 +381,7 @@ mod tests { 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)); + let outcome = liveness_scan_with_waits(&sidecar, &Endpoint::Unix(socket.clone()), Duration::from_millis(500), Duration::from_secs(3)); assert_eq!(outcome, LivenessOutcome::Cleaned, "刻度匹配的存活进程应被杀且文件被删"); // 有界等待回收线程:子进程被杀后收尸即退出,不无限阻塞。 let deadline = Instant::now() + Duration::from_secs(5); @@ -396,7 +411,7 @@ mod tests { // 注入短僵尸退出窗口(500 毫秒)加短杀进程等待(300 毫秒):子进程忽略 // SIGTERM,扫描判杀进程失败,文件原样。 let outcome = - liveness_scan_with_waits(&sidecar, &socket, Duration::from_millis(500), Duration::from_millis(300)); + liveness_scan_with_waits(&sidecar, &Endpoint::Unix(socket.clone()), Duration::from_millis(500), Duration::from_millis(300)); assert_eq!(outcome, LivenessOutcome::KillFailed, "忽略 SIGTERM 应判杀进程失败"); assert!(sidecar.exists(), "sidecar 应原样"); assert!(!socket.exists(), "socket 文件应保持不存在"); diff --git a/crates/oxide_cli/src/server/server.rs b/crates/oxide_cli/src/server/server.rs index 8e7f5b65..28782160 100644 --- a/crates/oxide_cli/src/server/server.rs +++ b/crates/oxide_cli/src/server/server.rs @@ -89,11 +89,11 @@ pub struct ServerConfig { } impl ServerConfig { - /// 生产默认配置:endpoint 与 sidecar 取 well-known 位置,worker 数取宿主 - /// 核数,版本取构建期版本。 + /// 生产默认配置:endpoint 取 well-known TCP 端口,sidecar 取 well-known + /// 位置,worker 数取宿主核数,版本取构建期版本。 pub fn well_known() -> Self { ServerConfig { - endpoint: Endpoint::Unix(sidecar::well_known_socket_path()), + endpoint: sidecar::well_known_endpoint(), sidecar_path: sidecar::well_known_sidecar_path(), worker_count: default_worker_count(), version: env!("CARGO_PKG_VERSION").to_string(), @@ -207,27 +207,23 @@ pub fn run_server(config: &ServerConfig) -> Result<(), ServerError> { levels: [Level::Info; SUBSYSTEM_COUNT], }); - // liveness 扫描、sidecar 认领与交接协议都在 Unix 文件面上;TCP 臂跳过 - // sidecar 注册,同端口裁决交给绑定失败。 - if let Endpoint::Unix(socket_path) = &config.endpoint { - // 启动时 liveness 扫描:认领裁决前清掉陈旧残留(僵尸态自动恢复)。 - // 杀进程失败映射既有的 ClaimRefused(RefusedAmbiguous),诊断指向人工 - // 清理命令。 - match liveness::liveness_scan(&config.sidecar_path, socket_path) { - liveness::LivenessOutcome::Noop | liveness::LivenessOutcome::Cleaned => {} - liveness::LivenessOutcome::KillFailed => { - return Err(ServerError::ClaimRefused(ClaimResult::RefusedAmbiguous)) - } + // 启动时 liveness 扫描:认领裁决前清掉陈旧残留(僵尸态自动恢复)。 + // 杀进程失败映射既有的 ClaimRefused(RefusedAmbiguous),诊断指向人工 + // 清理命令。两臂通用:TCP server 也写 sidecar,同端口裁决交给绑定失败。 + match liveness::liveness_scan(&config.sidecar_path, &config.endpoint) { + liveness::LivenessOutcome::Noop | liveness::LivenessOutcome::Cleaned => {} + liveness::LivenessOutcome::KillFailed => { + return Err(ServerError::ClaimRefused(ClaimResult::RefusedAmbiguous)) } + } - // 认领 sidecar(启动顺序契约:先写 sidecar 再绑定监听器):同版本 - // 拒绝启动,异版本走交接路径,存活证据不足即返回错误。 - match sidecar::claim_sidecar(&config.sidecar_path, socket_path, &config.version) { - ClaimResult::Registered => {} - ClaimResult::AlreadyRunning => return Err(ServerError::ClaimRefused(ClaimResult::AlreadyRunning)), - ClaimResult::VersionMismatch => yield_and_takeover(config)?, - ClaimResult::RefusedAmbiguous => return Err(ServerError::ClaimRefused(ClaimResult::RefusedAmbiguous)), - } + // 认领 sidecar(启动顺序契约:先写 sidecar 再绑定监听器):同版本 + // 拒绝启动,异版本走交接路径,存活证据不足即返回错误。 + match sidecar::claim_sidecar(&config.sidecar_path, &config.endpoint, &config.version) { + ClaimResult::Registered => {} + ClaimResult::AlreadyRunning => return Err(ServerError::ClaimRefused(ClaimResult::AlreadyRunning)), + ClaimResult::VersionMismatch => yield_and_takeover(config)?, + ClaimResult::RefusedAmbiguous => return Err(ServerError::ClaimRefused(ClaimResult::RefusedAmbiguous)), } // 关闭标志提前创建并注册信号处理器:在绑定 socket 之前,socket 文件 @@ -460,18 +456,15 @@ fn finish_shutdown( /// 2. 设 5 秒读超时,写 yield 请求帧。 /// 3. 读一帧应答:读超时(旧 server 挂起)或应答非 yield 确认(旧版本 /// binary 无该变体时以错误帧答复未知类型)判 `YieldFailed`。 -/// 4. 关闭连接,按 endpoint 形态等旧 server 退出:Unix 臂轮询 sidecar 文件 -/// 消失,TCP 臂以端口释放(连接被拒)为退出信号;均 50 毫秒间隔、 -/// 10 秒上限,超时判 `YieldFailed`。 -/// 5. Unix 臂重新认领 sidecar;得 `Registered` 即接管成功,其余态判 -/// `YieldFailed`。TCP 臂无 sidecar 文件,不重认领。 +/// 4. 关闭连接,轮询 sidecar 文件消失(50 毫秒间隔、10 秒上限)判旧 server +/// 退出;超时判 `YieldFailed`。 +/// 5. 重新认领 sidecar;得 `Registered` 即接管成功,其余态判 `YieldFailed`。 /// /// # 边界与前提 /// - 交接等待 10 秒长于旧 server 的排水安全超时 5 秒,正常路径必然先于 /// 等待上限完成。 -/// - Unix 臂:旧 server 先删 socket 文件后删 sidecar,sidecar 消失时 -/// socket 文件必已删除,接管绑定无竞态。 -/// - TCP 臂:监听者 drop 即端口释放,连接被拒即旧 server 已退出。 +/// - 两臂统一以 sidecar 消失为退出信号:旧 server 先删 socket 文件(Unix +/// 臂)后删 sidecar,sidecar 消失时接管绑定无竞态。 /// /// # 副作用 /// - 失败时不触碰旧 server 的文件(socket 与 sidecar),人工兜底归清理命令。 @@ -479,7 +472,7 @@ fn finish_shutdown( /// # 注意事项 /// - 纯函数式入口:版本管理路径(版本不匹配强制交接)与显式交接路径调用 /// 同一入口。 -/// - 退出信号源按形态分:Unix 臂是 sidecar 文件消失,TCP 臂是端口释放。 +/// - 退出信号源是 sidecar 文件消失(两臂同源,TCP server 也写 sidecar)。 pub fn yield_and_takeover(config: &ServerConfig) -> Result<(), ServerError> { // 连接旧 server:连接成功是交接的前提,两臂按 endpoint 形态分支。 let mut stream = match &config.endpoint { @@ -511,36 +504,20 @@ pub fn yield_and_takeover(config: &ServerConfig) -> Result<(), ServerError> { // 关闭连接:旧 server 已受理,交接进入退出等待阶段。 drop(reader); - match &config.endpoint { - // Unix 臂:轮询 sidecar 消失(旧 server 的退出序列以 sidecar 最后 - // 删除收口),消失后重新认领。 - Endpoint::Unix(path) => { - let deadline = Instant::now() + Duration::from_secs(10); - while config.sidecar_path.exists() { - if Instant::now() >= deadline { - return Err(ServerError::YieldFailed("旧 server 10 秒内未退出,sidecar 文件未消失".into())); - } - std::thread::sleep(Duration::from_millis(50)); - } + // 轮询 sidecar 消失(旧 server 的退出序列以 sidecar 最后删除收口), + // 消失后重新认领。两臂统一:TCP server 也写 sidecar,退出信号同源。 + let deadline = Instant::now() + Duration::from_secs(10); + while config.sidecar_path.exists() { + if Instant::now() >= deadline { + return Err(ServerError::YieldFailed("旧 server 10 秒内未退出,sidecar 文件未消失".into())); + } + std::thread::sleep(Duration::from_millis(50)); + } - // 重新认领:sidecar 已消失,O_EXCL 创建应成功;其余态判交接失败。 - match sidecar::claim_sidecar(&config.sidecar_path, path, &config.version) { - ClaimResult::Registered => Ok(()), - other => Err(ServerError::YieldFailed(format!("交接后重新认领未通过:{other:?}"))), - } - } - // TCP 臂:无 sidecar 文件可轮询,以端口释放(连接被拒)为旧 server - // 退出信号,10 秒上限,不重认领。 - Endpoint::Tcp(addr) => { - let deadline = Instant::now() + Duration::from_secs(10); - while TcpStream::connect(*addr).is_ok() { - if Instant::now() >= deadline { - return Err(ServerError::YieldFailed("旧 server 10 秒内未退出,端口仍被占用".into())); - } - std::thread::sleep(Duration::from_millis(50)); - } - Ok(()) - } + // 重新认领:sidecar 已消失,O_EXCL 创建应成功;其余态判交接失败。 + match sidecar::claim_sidecar(&config.sidecar_path, &config.endpoint, &config.version) { + ClaimResult::Registered => Ok(()), + other => Err(ServerError::YieldFailed(format!("交接后重新认领未通过:{other:?}"))), } } @@ -1316,7 +1293,7 @@ mod tests { // VersionMismatch)。 let listener = UnixListener::bind(unix_path(&config.endpoint)).expect("绑定模拟旧 server 应成功"); sidecar::write_exclusive( - &sidecar::ServerIdentity::new(unix_path(&config.endpoint).to_str().unwrap(), "0.0.0"), + &sidecar::ServerIdentity::new(&config.endpoint, "0.0.0"), &config.sidecar_path, ) .expect("写模拟 sidecar 应成功"); @@ -1349,7 +1326,7 @@ mod tests { let listener = UnixListener::bind(unix_path(&config.endpoint)).expect("绑定模拟旧 server 应成功"); sidecar::write_exclusive( - &sidecar::ServerIdentity::new(unix_path(&config.endpoint).to_str().unwrap(), "0.0.0"), + &sidecar::ServerIdentity::new(&config.endpoint, "0.0.0"), &config.sidecar_path, ) .expect("写模拟 sidecar 应成功"); @@ -1372,7 +1349,7 @@ mod tests { } /// 交接 TCP 臂:假 server 回 yield 确认帧后 drop 监听器(端口释放), - /// 交接以端口释放为退出信号返回成功(不重认领)。 + /// 无 sidecar 可轮询(轮询即跳过),重新认领 O_EXCL 创建成功,交接返回成功。 #[test] fn yield_takeover_tcp_arm() { let listener = TcpListener::bind("127.0.0.1:0").expect("绑定假 server 应成功"); diff --git a/crates/oxide_cli/src/server/sidecar.rs b/crates/oxide_cli/src/server/sidecar.rs index ee22dedf..9cc63ee6 100644 --- a/crates/oxide_cli/src/server/sidecar.rs +++ b/crates/oxide_cli/src/server/sidecar.rs @@ -13,7 +13,7 @@ use std::fs::{self, File, OpenOptions}; use std::io::{self, Write}; -use std::net::TcpStream; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream}; use std::os::unix::io::AsRawFd; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; @@ -27,7 +27,8 @@ use serde::{Deserialize, Serialize}; /// /// 字段: /// - `pid`:server 进程标识符,清理命令杀进程用。 -/// - `socket_path`:well-known socket 的绝对路径,控制客户端连接用。 +/// - `socket_path`:监听端点的文本形态(Unix 臂为路径、TCP 臂为 host:port), +/// 控制客户端连接用。 /// - `version`:构建期版本,版本交接时比对用。 /// - `started_at`:Unix 秒,人读展示用。 /// - `starttime`:`/proc//stat` 的启动时刻刻度(自开机单调递增), @@ -47,14 +48,16 @@ impl ServerIdentity { /// # 边界与前提 /// - 版本由调用方注入(生产默认取构建期版本,测试注入可区分值), /// 本函数不读取构建期版本。 + /// - `socket_path` 字段记监听端点的文本形态(`endpoint.describe()`), + /// 字段名与 JSON 模式不变,旧 sidecar 可读。 /// - 非 Linux 宿主(`/proc` 缺位)时 `starttime` 填 0,交叉核对退化, - /// liveness 由 socket 探活裁决。 - pub fn new(socket_path: impl AsRef, version: &str) -> Self { + /// liveness 由 endpoint 探活裁决。 + pub fn new(endpoint: &Endpoint, 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(), + socket_path: endpoint.describe(), version: version.to_string(), started_at, starttime: process_starttime(pid).unwrap_or(0), @@ -81,16 +84,36 @@ pub fn current_uid() -> u32 { 0 } +/// well-known TCP 端口:持久 server 缺省端口。 +pub const WELL_KNOWN_PORT: u16 = 4242; + /// well-known sidecar 路径:`$TMPDIR/oxide-.json`。 pub fn well_known_sidecar_path() -> PathBuf { std::env::temp_dir().join(format!("oxide-{}.json", current_uid())) } /// well-known 持久 server socket 路径:`$TMPDIR/oxide-.sock`。 +/// +/// 保留给 `--rm` 独立模式(进程唯一路径)与旧残留清理;持久 server 缺省 +/// 端点已切到 well-known TCP 端口。 pub fn well_known_socket_path() -> PathBuf { std::env::temp_dir().join(format!("oxide-{}.sock", current_uid())) } +/// well-known TCP 端点:127.0.0.1:WELL_KNOWN_PORT(绑定地址固定 loopback)。 +pub fn well_known_endpoint() -> Endpoint { + Endpoint::Tcp(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), WELL_KNOWN_PORT)) +} + +/// 解析端点文本形态:先试 `SocketAddr`(host:port)得 TCP 臂,失败回 +/// Unix 臂(路径形态)。sidecar 读写的单一入口,解析放这里。 +pub fn parse_endpoint(s: &str) -> Endpoint { + match s.parse::() { + Ok(addr) => Endpoint::Tcp(addr), + Err(_) => Endpoint::Unix(PathBuf::from(s)), + } +} + /// well-known 日志文件路径:`$TMPDIR/oxide-.log`(与 sidecar 同主名)。 pub fn well_known_log_path() -> PathBuf { well_known_sidecar_path().with_extension("log") @@ -229,86 +252,98 @@ pub enum ClaimResult { /// /// # 步骤 /// 1. 尝试 O_EXCL 创建;sidecar 不存在即成功。 -/// 2. sidecar 已存在:探活 socket,连接成功即有存活 server,读回旧 sidecar +/// 2. sidecar 已存在:先读回,探活 sidecar 记录的 endpoint(不可读时探 +/// 调用方 endpoint 兜底),连接成功即有存活 server,读回旧 sidecar /// 与本进程版本比对:匹配判 `AlreadyRunning`,不匹配或不可读判 /// `VersionMismatch`。 -/// 3. socket 死:按 sidecar 可读性分支。 -/// - 可解析且 PID 死:陈旧残留,删 sidecar 与 socket 文件后重建。 +/// 3. endpoint 死:按 sidecar 可读性分支。 +/// - 可解析且 PID 死:陈旧残留,删 sidecar 与 socket 文件(Unix 臂)后重建。 /// - 可解析且 PID 活:交叉核对启动时刻刻度,匹配则保守拒绝(server -/// 可能正在启动或 socket 被外部删除),失配则 PID 已复用,删除重建。 -/// - 不可解析(损坏):删 sidecar 与 socket 文件后重建。 +/// 可能正在启动或 endpoint 被外部释放),失配则 PID 已复用,删除重建。 +/// - 不可解析(损坏):删 sidecar 与 socket 文件(Unix 臂)后重建。 /// /// # 边界与前提 -/// - 启动顺序契约是「先写 sidecar 再 bind socket」,存活 server 必有 sidecar。 +/// - 启动顺序契约是「先写 sidecar 再 bind endpoint」,存活 server 必有 sidecar。 +/// - 探活必须探 sidecar 记录的 endpoint 而非调用方自己的 endpoint:端口 +/// 不同时探自己会把存活 server 误判为死并破坏其 sidecar(双注册场景)。 /// - 版本比对是精确字符串相等(同一性判定,不是新旧次序判定);旧 sidecar /// 不可读(损坏、缺 version 字段)按不匹配处理,方向安全(强制交接失败 /// 不触碰旧 server 的文件,旧 server 继续运行)。 /// /// # 副作用 -/// - 可能创建、删除、重写 sidecar 与 socket 文件。 +/// - 可能创建、删除、重写 sidecar 与 socket 文件(Unix 臂)。 /// /// # 注意事项 /// - 意外 io 错误(权限等)保守判 `RefusedAmbiguous`;方向安全(拒绝注册 /// 不会双注册)。 -pub fn claim_sidecar(sidecar_path: &Path, socket_path: &Path, new_version: &str) -> ClaimResult { +pub fn claim_sidecar(sidecar_path: &Path, endpoint: &Endpoint, new_version: &str) -> ClaimResult { // 先尝试 O_EXCL 创建:sidecar 不存在即成功。 - match write_exclusive(&ServerIdentity::new(socket_path, new_version), sidecar_path) { + match write_exclusive(&ServerIdentity::new(endpoint, new_version), sidecar_path) { Ok(()) => return ClaimResult::Registered, Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {} Err(_) => return ClaimResult::RefusedAmbiguous, } - // socket 探活是存活的唯一事实源:连接成功即有存活 server。 - if is_server_alive(&Endpoint::Unix(socket_path.to_path_buf())) { + // sidecar 已存在:先读回。探活目标是 sidecar 记录的 endpoint(正确性 + // 红线:探调用方自己的端口会在端口不同时把存活 server 误判为死); + // 不可读时探调用方 endpoint(损坏文件防御性兜底,探自己)。 + let recorded = read_identity(sidecar_path); + let probe = match &recorded { + Some(id) => parse_endpoint(&id.socket_path), + None => endpoint.clone(), + }; + if is_server_alive(&probe) { // 读回旧 sidecar 与本进程版本比对:匹配拒绝启动,不匹配或不可读 // 交调用方强制交接。 - match read_identity(sidecar_path) { + match &recorded { 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, new_version), + // endpoint 死:按 sidecar 可读性分支。 + match recorded { + Some(id) => claim_dead_socket(&id, sidecar_path, endpoint, new_version), None => { - // 损坏文件:删 sidecar 与 socket 文件后重建(socket 探活已失败, - // 文件无监听者,不会误删存活 server 的 socket)。 - remove_stale(sidecar_path, socket_path); - re_register(sidecar_path, socket_path, new_version) + // 损坏文件:删 sidecar 与 socket 文件(Unix 臂)后重建(endpoint + // 探活已失败,文件无监听者,不会误删存活 server 的 socket)。 + remove_stale(sidecar_path, endpoint); + re_register(sidecar_path, endpoint, new_version) } } } -/// socket 死且 sidecar 可解析:按 PID 与启动时刻刻度裁决陈旧性。 -fn claim_dead_socket(id: &ServerIdentity, sidecar_path: &Path, socket_path: &Path, new_version: &str) -> ClaimResult { - // PID 死:陈旧残留,删 sidecar 与 socket 文件后重建。 +/// endpoint 死且 sidecar 可解析:按 PID 与启动时刻刻度裁决陈旧性。 +fn claim_dead_socket(id: &ServerIdentity, sidecar_path: &Path, endpoint: &Endpoint, new_version: &str) -> ClaimResult { + // PID 死:陈旧残留,删 sidecar 与 socket 文件(Unix 臂)后重建。 if !pid_alive(id.pid) { - remove_stale(sidecar_path, socket_path); - return re_register(sidecar_path, socket_path, new_version); + remove_stale(sidecar_path, endpoint); + return re_register(sidecar_path, endpoint, new_version); } // PID 活:交叉核对启动时刻刻度裁决 PID 复用。 if process_starttime(id.pid) == Some(id.starttime) { - // 进程活且刻度匹配:server 可能正在启动或 socket 被外部删除,保守拒绝。 + // 进程活且刻度匹配:server 可能正在启动或 endpoint 被外部释放,保守拒绝。 return ClaimResult::RefusedAmbiguous; } // 刻度失配:PID 已被复用,sidecar 是陈旧残留。 - remove_stale(sidecar_path, socket_path); - re_register(sidecar_path, socket_path, new_version) + remove_stale(sidecar_path, endpoint); + re_register(sidecar_path, endpoint, new_version) } -/// 删除 sidecar 与 socket 文件(socket 探活已失败,文件无监听者,不会误删 -/// 存活 server 的 socket)。 -fn remove_stale(sidecar_path: &Path, socket_path: &Path) { +/// 删除 sidecar 与 socket 文件(Unix 臂;endpoint 探活已失败,文件无监听者, +/// 不会误删存活 server 的 socket)。TCP 臂无文件可删,只删 sidecar。 +fn remove_stale(sidecar_path: &Path, endpoint: &Endpoint) { let _ = fs::remove_file(sidecar_path); - let _ = fs::remove_file(socket_path); + if let Some(path) = endpoint.socket_path() { + let _ = fs::remove_file(path); + } } /// 清理后重建 sidecar,重建失败保守拒绝。 -fn re_register(sidecar_path: &Path, socket_path: &Path, new_version: &str) -> ClaimResult { - match write_exclusive(&ServerIdentity::new(socket_path, new_version), sidecar_path) { +fn re_register(sidecar_path: &Path, endpoint: &Endpoint, new_version: &str) -> ClaimResult { + match write_exclusive(&ServerIdentity::new(endpoint, new_version), sidecar_path) { Ok(()) => ClaimResult::Registered, Err(_) => ClaimResult::RefusedAmbiguous, } @@ -381,7 +416,7 @@ mod tests { fn identity_roundtrip() { let dir = TestDir::new(); let path = dir.path("sidecar.json"); - let id = ServerIdentity::new("/tmp/oxide-test.sock", "0.0.0"); + let id = ServerIdentity::new(&Endpoint::Unix(PathBuf::from("/tmp/oxide-test.sock")), "0.0.0"); write_exclusive(&id, &path).expect("写 sidecar 应成功"); let read = read_identity(&path).expect("读回 sidecar 应成功"); assert_eq!(read, id, "五字段应逐字段一致"); @@ -392,19 +427,28 @@ 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", "0.0.0"); + let id = ServerIdentity::new(&Endpoint::Unix(PathBuf::from("/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"); } + /// 端点文本解析:host:port 得 TCP 臂,路径形态回 Unix 臂。 + #[test] + fn parse_endpoint_two_forms() { + let tcp = parse_endpoint("127.0.0.1:4242"); + assert_eq!(tcp, Endpoint::Tcp(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4242)), "host:port 应得 TCP 臂"); + let unix = parse_endpoint("/tmp/oxide-test.sock"); + assert_eq!(unix, Endpoint::Unix(PathBuf::from("/tmp/oxide-test.sock")), "路径形态应得 Unix 臂"); + } + /// 认领分支:无 sidecar 时注册成功,sidecar 是本进程身份。 #[test] fn claim_no_sidecar_registers() { let dir = TestDir::new(); let sidecar = dir.path("sidecar.json"); let socket = dir.path("server.sock"); - assert_eq!(claim_sidecar(&sidecar, &socket, "0.0.0"), ClaimResult::Registered); + assert_eq!(claim_sidecar(&sidecar, &Endpoint::Unix(socket.clone()), "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()); @@ -417,10 +461,10 @@ mod tests { let dir = TestDir::new(); let sidecar = dir.path("sidecar.json"); let socket = dir.path("server.sock"); - // 存活 server 必有 sidecar(启动顺序契约:先写 sidecar 再 bind socket)。 + // 存活 server 必有 sidecar(启动顺序契约:先写 sidecar 再 bind endpoint)。 let listener = UnixListener::bind(&socket).expect("绑定监听者应成功"); - 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); + write_exclusive(&ServerIdentity::new(&Endpoint::Unix(socket.clone()), "0.0.0"), &sidecar).expect("写 sidecar 应成功"); + assert_eq!(claim_sidecar(&sidecar, &Endpoint::Unix(socket.clone()), "0.0.0"), ClaimResult::AlreadyRunning); drop(listener); } @@ -433,8 +477,8 @@ mod tests { 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); + write_exclusive(&ServerIdentity::new(&Endpoint::Unix(socket.clone()), "0.0.0"), &sidecar).expect("写 sidecar 应成功"); + assert_eq!(claim_sidecar(&sidecar, &Endpoint::Unix(socket.clone()), "0.0.1"), ClaimResult::VersionMismatch); drop(listener); } @@ -445,7 +489,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, "0.0.0"), ClaimResult::Registered); + assert_eq!(claim_sidecar(&sidecar, &Endpoint::Unix(socket.clone()), "0.0.0"), ClaimResult::Registered); let id = read_identity(&sidecar).expect("重建 sidecar 应可读"); assert_eq!(id.pid, std::process::id(), "重建 sidecar 应为本进程身份"); } @@ -458,7 +502,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, "0.0.0"), ClaimResult::Registered); + assert_eq!(claim_sidecar(&sidecar, &Endpoint::Unix(socket.clone()), "0.0.0"), ClaimResult::Registered); } /// 认领分支:存活 PID 加匹配启动时刻保守拒绝。 @@ -468,9 +512,9 @@ mod tests { let sidecar = dir.path("sidecar.json"); let socket = dir.path("server.sock"); // PID 与启动时刻刻度都是本进程的:进程活且刻度匹配。 - let id = ServerIdentity::new(socket.to_str().unwrap(), "0.0.0"); + let id = ServerIdentity::new(&Endpoint::Unix(socket.clone()), "0.0.0"); write_exclusive(&id, &sidecar).expect("写 sidecar 应成功"); - assert_eq!(claim_sidecar(&sidecar, &socket, "0.0.0"), ClaimResult::RefusedAmbiguous); + assert_eq!(claim_sidecar(&sidecar, &Endpoint::Unix(socket.clone()), "0.0.0"), ClaimResult::RefusedAmbiguous); } /// 认领分支:损坏文件加死 socket 判陈旧,sidecar 重建。 @@ -481,7 +525,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, "0.0.0"), ClaimResult::Registered); + assert_eq!(claim_sidecar(&sidecar, &Endpoint::Unix(socket.clone()), "0.0.0"), ClaimResult::Registered); let id = read_identity(&sidecar).expect("重建 sidecar 应可读"); assert_eq!(id.pid, std::process::id()); } @@ -496,7 +540,7 @@ mod tests { fs::write(&sidecar, "not json at all").expect("写损坏文件应成功"); // 陈旧 socket 文件:文件存在但无监听者。 fs::write(&socket, b"stale").expect("写陈旧 socket 文件应成功"); - assert_eq!(claim_sidecar(&sidecar, &socket, "0.0.0"), ClaimResult::Registered); + assert_eq!(claim_sidecar(&sidecar, &Endpoint::Unix(socket.clone()), "0.0.0"), ClaimResult::Registered); let id = read_identity(&sidecar).expect("重建 sidecar 应可读"); assert_eq!(id.pid, std::process::id(), "重建 sidecar 应为本进程身份"); assert!(!socket.exists(), "陈旧 socket 文件应被删除"); @@ -511,10 +555,22 @@ mod tests { 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, "0.0.0"), ClaimResult::VersionMismatch); + assert_eq!(claim_sidecar(&sidecar, &Endpoint::Unix(socket.clone()), "0.0.0"), ClaimResult::VersionMismatch); drop(listener); } + /// 认领分支(TCP 臂):无 sidecar 时注册成功,sidecar 记 host:port 文本形态。 + #[test] + fn claim_tcp_no_sidecar_registers() { + let dir = TestDir::new(); + let sidecar = dir.path("sidecar.json"); + let endpoint = Endpoint::Tcp(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)); + assert_eq!(claim_sidecar(&sidecar, &endpoint, "0.0.0"), ClaimResult::Registered); + let id = read_identity(&sidecar).expect("sidecar 应已写入"); + assert_eq!(id.pid, std::process::id()); + assert_eq!(id.socket_path, endpoint.describe(), "sidecar 应记 host:port 文本形态"); + } + /// --rm 路径:两次调用路径不同,且文件名含本进程 PID 与 oxide-rm- 前缀。 #[test] fn rm_socket_path_unique_and_contains_pid() { diff --git a/crates/oxide_cli/src/server/spawn.rs b/crates/oxide_cli/src/server/spawn.rs index 860551fd..825b2ecb 100644 --- a/crates/oxide_cli/src/server/spawn.rs +++ b/crates/oxide_cli/src/server/spawn.rs @@ -10,23 +10,25 @@ use std::time::{Duration, Instant}; use super::client::send_control_request; use super::protocol::{ServerRequest, ServerResponse}; -/// spawn 脱离的前台 server 子进程(`server start --foreground`):标准流全空,不等待。 +/// spawn 脱离的前台 server 子进程(`server start --foreground --port N`): +/// 标准流全空,不等待。 /// /// # 步骤 /// 1. 取当前可执行文件路径。 -/// 2. 构造命令 `server start --foreground` 加可选 `--workers N`。 +/// 2. 构造命令 `server start --foreground --port N` 加可选 `--workers N`。 /// 3. 三个标准流置空并 spawn。 /// /// # 边界与前提 /// - 必须带 `--foreground`:守护形态 spawn 的是前台 server 入口,不带该旗标会 /// 再次进入守护形态形成递归 spawn。 +/// - `port` 是 TCP 监听端口;缺省取 well-known 端口(调用方传入)。 /// /// # 副作用 /// - 创建脱离的子进程,调用方退出后由 init 收领继续运行。 -pub fn spawn_detached_server(workers: Option) -> std::io::Result { +pub fn spawn_detached_server(workers: Option, port: u16) -> std::io::Result { let exe = std::env::current_exe()?; let mut cmd = Command::new(exe); - cmd.args(["server", "start", "--foreground"]); + cmd.args(["server", "start", "--foreground", "--port", &port.to_string()]); if let Some(n) = workers { cmd.args(["--workers", &n.to_string()]); } diff --git a/crates/oxide_cli/src/server/watchdog.rs b/crates/oxide_cli/src/server/watchdog.rs index 8a09f1ea..e9e2c55f 100644 --- a/crates/oxide_cli/src/server/watchdog.rs +++ b/crates/oxide_cli/src/server/watchdog.rs @@ -158,8 +158,9 @@ pub fn run_watchdog(workers: Option) -> ExitCode { return ExitCode::FAILURE; } - // 初始 spawn 与就绪探测:失败即退 1,不进入监控循环。 - let mut child = match spawn_detached_server(workers) { + // 初始 spawn 与就绪探测:失败即退 1,不进入监控循环。watchdog 子进程用 + // well-known 端口(范围控制:不加 --port 旗标)。 + let mut child = match spawn_detached_server(workers, sidecar::WELL_KNOWN_PORT) { Ok(child) => child, Err(err) => { eprintln!("spawn server 进程失败:{err}"); @@ -245,7 +246,7 @@ pub fn run_watchdog(workers: Option) -> ExitCode { return ExitCode::FAILURE; } std::thread::sleep(CRASH_BACKOFF); - match spawn_detached_server(workers) { + match spawn_detached_server(workers, sidecar::WELL_KNOWN_PORT) { Ok(new_child) => { child = new_child; child_exited = None; diff --git a/crates/oxide_cli/tests/server_cli.rs b/crates/oxide_cli/tests/server_cli.rs index 7d6cbad5..49514d0f 100644 --- a/crates/oxide_cli/tests/server_cli.rs +++ b/crates/oxide_cli/tests/server_cli.rs @@ -7,7 +7,8 @@ //! 触碰全局路径的测试经双重锁(进程内互斥加跨进程文件锁)串行,避免相互 //! 干扰(含与单测二进制的并行干扰)。 -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader}; +use std::net::TcpStream; use std::os::unix::net::UnixStream; use std::process::{Command, Output}; use std::sync::{Mutex, MutexGuard}; @@ -16,7 +17,7 @@ use std::time::{Duration, Instant}; use oxide_cli::server::client::send_control_request; use oxide_cli::server::protocol::{self, FrameReader, ServerRequest, ServerResponse}; use oxide_cli::server::sidecar; -use oxide_net::Endpoint; +use oxide_net::{Conn, Endpoint}; /// well-known 全局路径进程内互斥锁:同测试二进制内触碰全局路径的测试串行 /// 执行(fcntl 锁以进程为单位,不覆盖同进程多线程)。 @@ -85,7 +86,7 @@ fn wait_pid_dead(pid: u32, deadline: Duration) { } /// 发送请求帧并读回一帧响应。 -fn send_and_recv(stream: &mut UnixStream, request: &ServerRequest) -> ServerResponse { +fn send_and_recv(stream: &mut Conn, request: &ServerRequest) -> ServerResponse { stream .write_all(protocol::encode_request(request).as_bytes()) .expect("写请求应成功"); @@ -94,6 +95,15 @@ fn send_and_recv(stream: &mut UnixStream, request: &ServerRequest) -> ServerResp protocol::parse_response(&frame).expect("响应帧解析应成功") } +/// 读 well-known sidecar 取 endpoint 并连接 server(两臂按形态分支)。 +fn connect_well_known() -> Conn { + let id = sidecar::read_identity(&sidecar::well_known_sidecar_path()).expect("读 sidecar 应成功"); + match sidecar::parse_endpoint(&id.socket_path) { + Endpoint::Unix(path) => Conn::Unix(UnixStream::connect(&path).expect("连接 server 应成功")), + Endpoint::Tcp(addr) => Conn::Tcp(TcpStream::connect(addr).expect("连接 server 应成功")), + } +} + /// help 列全部十一个子命令。 #[test] fn server_help_lists_subcommands() { @@ -115,7 +125,7 @@ fn server_help_lists_subcommands() { fn server_log_no_file_exit_1() { let _guard = lock_well_known(); assert!( - !sidecar::is_server_alive(&Endpoint::Unix(sidecar::well_known_socket_path())), + !sidecar::is_server_alive(&sidecar::well_known_endpoint()), "存活 server 占用 well-known 路径,测试不抢占" ); @@ -135,7 +145,7 @@ fn server_log_no_file_exit_1() { fn server_control_arms_no_server_exit_1() { let _guard = lock_well_known(); assert!( - !sidecar::is_server_alive(&Endpoint::Unix(sidecar::well_known_socket_path())), + !sidecar::is_server_alive(&sidecar::well_known_endpoint()), "存活 server 占用 well-known 路径,测试不抢占" ); @@ -154,7 +164,7 @@ fn server_control_arms_no_server_exit_1() { fn server_forge_no_server_exit_1() { let _guard = lock_well_known(); assert!( - !sidecar::is_server_alive(&Endpoint::Unix(sidecar::well_known_socket_path())), + !sidecar::is_server_alive(&sidecar::well_known_endpoint()), "存活 server 占用 well-known 路径,测试不抢占" ); @@ -169,7 +179,7 @@ fn server_forge_no_server_exit_1() { fn server_cleanup_exit_0() { let _guard = lock_well_known(); assert!( - !sidecar::is_server_alive(&Endpoint::Unix(sidecar::well_known_socket_path())), + !sidecar::is_server_alive(&sidecar::well_known_endpoint()), "存活 server 占用 well-known 路径,测试不抢占" ); @@ -185,12 +195,11 @@ fn server_cleanup_exit_0() { #[test] fn server_start_e2e() { let _guard = lock_well_known(); - let socket = sidecar::well_known_socket_path(); let sidecar_path = sidecar::well_known_sidecar_path(); // 探活先行:存活 server 占用全局路径时 panic,不抢占。 assert!( - !sidecar::is_server_alive(&Endpoint::Unix(socket.clone())), + !sidecar::is_server_alive(&sidecar::well_known_endpoint()), "存活 server 占用 well-known 路径,测试不抢占" ); @@ -205,7 +214,7 @@ fn server_start_e2e() { let id = sidecar::read_identity(&sidecar_path).expect("读 sidecar 应成功"); // 版本请求帧:版本应等于构建期版本。 - let mut stream = UnixStream::connect(&socket).expect("连接 server 应成功"); + let mut stream = connect_well_known(); let response = send_and_recv(&mut stream, &ServerRequest::Version); match response { ServerResponse::Version { version } => { @@ -226,7 +235,6 @@ fn server_start_e2e() { std::thread::sleep(Duration::from_millis(50)); } - assert!(!socket.exists(), "socket 文件应被删除"); assert!(!sidecar_path.exists(), "sidecar 文件应被删除"); wait_pid_dead(id.pid, Duration::from_secs(30)); } @@ -237,12 +245,11 @@ fn server_start_e2e() { #[test] fn server_start_when_running() { let _guard = lock_well_known(); - let socket = sidecar::well_known_socket_path(); let sidecar_path = sidecar::well_known_sidecar_path(); // 探活先行:存活 server 占用全局路径时 panic,不抢占。 assert!( - !sidecar::is_server_alive(&Endpoint::Unix(socket.clone())), + !sidecar::is_server_alive(&sidecar::well_known_endpoint()), "存活 server 占用 well-known 路径,测试不抢占" ); @@ -259,7 +266,7 @@ fn server_start_when_running() { assert!(stdout.contains("已启动"), "二次 start 应打印「已启动」:{stdout}"); // 收尾:发关闭请求,等 sidecar 消失。 - let mut stream = UnixStream::connect(&socket).expect("连接 server 应成功"); + let mut stream = connect_well_known(); let response = send_and_recv(&mut stream, &ServerRequest::Shutdown); assert!(matches!(response, ServerResponse::Shutdown), "应得关闭确认帧:{response:?}"); drop(stream); @@ -268,7 +275,6 @@ fn server_start_when_running() { assert!(Instant::now() < deadline, "server 应在有界时间内退出"); std::thread::sleep(Duration::from_millis(50)); } - assert!(!socket.exists(), "socket 文件应被删除"); assert!(!sidecar_path.exists(), "sidecar 文件应被删除"); } @@ -278,23 +284,22 @@ fn server_start_when_running() { #[test] fn server_restart_e2e() { let _guard = lock_well_known(); - let socket = sidecar::well_known_socket_path(); let sidecar_path = sidecar::well_known_sidecar_path(); // 探活先行:存活 server 占用全局路径时 panic,不抢占。 assert!( - !sidecar::is_server_alive(&Endpoint::Unix(socket.clone())), + !sidecar::is_server_alive(&sidecar::well_known_endpoint()), "存活 server 占用 well-known 路径,测试不抢占" ); - // 起子进程 A:worker 数压到 2,轮询 socket 文件出现(10 秒截止)判就绪。 + // 起子进程 A:worker 数压到 2,轮询 sidecar 出现(10 秒截止)判就绪。 let mut child = Command::new(env!("CARGO_BIN_EXE_oxide")) .args(["server", "start", "--workers", "2"]) .spawn() .expect("oxide server start 应可启动"); let deadline = Instant::now() + Duration::from_secs(10); - while !socket.exists() { - assert!(Instant::now() < deadline, "socket 文件未出现"); + while sidecar::read_identity(&sidecar_path).is_none() { + assert!(Instant::now() < deadline, "sidecar 未出现"); std::thread::sleep(Duration::from_millis(10)); } @@ -329,7 +334,7 @@ fn server_restart_e2e() { assert_eq!(new_id.version, env!("CARGO_PKG_VERSION"), "新 sidecar 版本应与构建期版本一致"); // 连接新 server:健康请求得 healthy,版本请求得构建期版本。 - let mut stream = UnixStream::connect(&socket).expect("连接新 server 应成功"); + let mut stream = connect_well_known(); match send_and_recv(&mut stream, &ServerRequest::Health) { ServerResponse::Health { healthy } => assert!(healthy, "新 server 应健康"), other => panic!("应得 Health 帧,实得 {other:?}"), @@ -341,7 +346,7 @@ fn server_restart_e2e() { other => panic!("应得 Version 帧,实得 {other:?}"), } - // 收尾:发关闭请求,等新 server 退出,断言 socket 与 sidecar 文件被删。 + // 收尾:发关闭请求,等新 server 退出,断言 sidecar 文件被删。 let response = send_and_recv(&mut stream, &ServerRequest::Shutdown); assert!(matches!(response, ServerResponse::Shutdown), "应得关闭确认帧:{response:?}"); drop(stream); @@ -350,7 +355,6 @@ fn server_restart_e2e() { assert!(Instant::now() < deadline, "新 server 应在有界时间内退出"); std::thread::sleep(Duration::from_millis(50)); } - assert!(!socket.exists(), "socket 文件应被删除"); assert!(!sidecar_path.exists(), "sidecar 文件应被删除"); } @@ -361,13 +365,12 @@ fn server_restart_e2e() { #[test] fn server_log_e2e() { let _guard = lock_well_known(); - let socket = sidecar::well_known_socket_path(); let sidecar_path = sidecar::well_known_sidecar_path(); let log_path = sidecar::well_known_log_path(); // 探活先行:存活 server 占用全局路径时 panic,不抢占。 assert!( - !sidecar::is_server_alive(&Endpoint::Unix(socket.clone())), + !sidecar::is_server_alive(&sidecar::well_known_endpoint()), "存活 server 占用 well-known 路径,测试不抢占" ); @@ -419,7 +422,7 @@ fn server_log_e2e() { ); // 关闭请求帧:读回关闭确认帧。 - let mut stream = UnixStream::connect(&socket).expect("连接 server 应成功"); + let mut stream = connect_well_known(); let response = send_and_recv(&mut stream, &ServerRequest::Shutdown); assert!(matches!(response, ServerResponse::Shutdown), "应得关闭确认帧:{response:?}"); drop(stream); @@ -445,22 +448,21 @@ fn server_log_e2e() { #[test] fn server_forge_e2e() { let _guard = lock_well_known(); - let socket = sidecar::well_known_socket_path(); let sidecar_path = sidecar::well_known_sidecar_path(); // 探活先行:存活 server 占用全局路径时 panic,不抢占。 assert!( - !sidecar::is_server_alive(&Endpoint::Unix(socket.clone())), + !sidecar::is_server_alive(&sidecar::well_known_endpoint()), "存活 server 占用 well-known 路径,测试不抢占" ); - // start 为守护形态:CLI 即刻返回,server 是脱离的孙进程;轮询 socket 文件 + // start 为守护形态:CLI 即刻返回,server 是脱离的孙进程;轮询 sidecar // 出现判就绪(worker 数压到 2 避免按宿主核数预热的成本)。 let output = oxide(&["server", "start", "--workers", "2"]); assert_eq!(output.status.code(), Some(0), "start 应退 0"); let deadline = Instant::now() + Duration::from_secs(10); - while !socket.exists() { - assert!(Instant::now() < deadline, "socket 文件未出现"); + while sidecar::read_identity(&sidecar_path).is_none() { + assert!(Instant::now() < deadline, "sidecar 未出现"); std::thread::sleep(Duration::from_millis(10)); } @@ -485,7 +487,7 @@ fn server_forge_e2e() { assert!(stdout.contains("cache cleared"), "forge --clear-cache 应打印清缓存行:{stdout}"); // 关闭请求帧:读回关闭确认帧。 - let mut stream = UnixStream::connect(&socket).expect("连接 server 应成功"); + let mut stream = connect_well_known(); let response = send_and_recv(&mut stream, &ServerRequest::Shutdown); assert!(matches!(response, ServerResponse::Shutdown), "应得关闭确认帧:{response:?}"); drop(stream); @@ -497,7 +499,6 @@ fn server_forge_e2e() { std::thread::sleep(Duration::from_millis(50)); } - assert!(!socket.exists(), "socket 文件应被删除"); assert!(!sidecar_path.exists(), "sidecar 文件应被删除"); } @@ -507,12 +508,11 @@ fn server_forge_e2e() { #[test] fn server_watchdog_e2e() { let _guard = lock_well_known(); - let socket = sidecar::well_known_socket_path(); let sidecar_path = sidecar::well_known_sidecar_path(); // 探活先行:存活 server 占用全局路径时 panic,不抢占。 assert!( - !sidecar::is_server_alive(&Endpoint::Unix(socket.clone())), + !sidecar::is_server_alive(&sidecar::well_known_endpoint()), "存活 server 占用 well-known 路径,测试不抢占" ); @@ -582,7 +582,7 @@ fn server_watchdog_e2e() { .expect("watchdog 应在有界时间内完成重启探测"); // 连接新 server:健康请求得 healthy。 - let mut stream = UnixStream::connect(&socket).expect("连接新 server 应成功"); + let mut stream = connect_well_known(); match send_and_recv(&mut stream, &ServerRequest::Health) { ServerResponse::Health { healthy } => assert!(healthy, "新 server 应健康"), other => panic!("应得 Health 帧,实得 {other:?}"), @@ -613,12 +613,11 @@ fn server_watchdog_e2e() { #[test] fn server_watchdog_sigint_stops_server() { let _guard = lock_well_known(); - let socket = sidecar::well_known_socket_path(); let sidecar_path = sidecar::well_known_sidecar_path(); // 探活先行:存活 server 占用全局路径时 panic,不抢占。 assert!( - !sidecar::is_server_alive(&Endpoint::Unix(socket.clone())), + !sidecar::is_server_alive(&sidecar::well_known_endpoint()), "存活 server 占用 well-known 路径,测试不抢占" ); diff --git a/crates/oxide_cli/tests/server_client.rs b/crates/oxide_cli/tests/server_client.rs index 098eeaf1..c5843c0f 100644 --- a/crates/oxide_cli/tests/server_client.rs +++ b/crates/oxide_cli/tests/server_client.rs @@ -99,3 +99,67 @@ fn control_client_end_to_end() { ); assert!(!config.sidecar_path.exists(), "sidecar 文件应被删除"); } + +/// TCP 臂端到端:bind 127.0.0.1:0 取空闲端口、drop 监听器、以该端口起 server, +/// 经控制客户端走五类请求加关闭,sidecar 记 host:port 文本形态且被删。 +#[test] +fn control_client_tcp_arm_end_to_end() { + // bind 127.0.0.1:0 取空闲端口,drop 监听器释放端口。 + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("取空闲端口应成功"); + let addr = listener.local_addr().expect("取端口应成功"); + drop(listener); + + // TCP 配置:临时 sidecar 路径、worker 数 2、版本取构建期版本。 + let dir = std::env::temp_dir().join(format!("oxide_server_client_tcp_{}_{}", std::process::id(), "tcp")); + fs::create_dir_all(&dir).expect("测试目录创建应成功"); + let config = ServerConfig { + endpoint: Endpoint::Tcp(addr), + sidecar_path: dir.join("sidecar.json"), + worker_count: 2, + version: env!("CARGO_PKG_VERSION").to_string(), + }; + + // 后台线程起真实 run_server,等 sidecar 出现(TCP 臂无 socket 文件, + // sidecar 是就绪信号)。 + let server_config = config.clone(); + let handle = std::thread::spawn(move || run_server(&server_config)); + let deadline = Instant::now() + Duration::from_secs(10); + while oxide_cli::server::sidecar::read_identity(&config.sidecar_path).is_none() { + assert!(Instant::now() < deadline, "sidecar 未出现"); + std::thread::sleep(Duration::from_millis(10)); + } + + // 版本请求:版本与构建期版本一致。 + let response = send_control_request_from(&config.sidecar_path, &ServerRequest::Version).expect("版本请求应成功"); + match response { + ServerResponse::Version { version } => { + assert_eq!(version, env!("CARGO_PKG_VERSION"), "版本应与构建期版本一致"); + } + other => panic!("应得 Version 帧,实得 {other:?}"), + } + + // 健康请求:healthy 为真。 + let response = send_control_request_from(&config.sidecar_path, &ServerRequest::Health).expect("健康请求应成功"); + assert!( + matches!(response, ServerResponse::Health { healthy: true }), + "healthy 应为真:{response:?}" + ); + + // 信息请求:endpoint 值与配置一致(host:port 文本形态)、进程号为当前进程号。 + let response = send_control_request_from(&config.sidecar_path, &ServerRequest::Info).expect("信息请求应成功"); + match response { + ServerResponse::Info { socket_path, pid, .. } => { + assert_eq!(socket_path, config.endpoint.describe(), "endpoint 值应与配置一致"); + assert_eq!(pid, std::process::id(), "进程号应为当前进程号"); + } + other => panic!("应得 Info 帧,实得 {other:?}"), + } + + // 关闭请求:得确认帧,server 线程正常退出,sidecar 文件被删(TCP 臂无 + // socket 文件)。 + let response = send_control_request_from(&config.sidecar_path, &ServerRequest::Shutdown).expect("关闭请求应成功"); + assert!(matches!(response, ServerResponse::Shutdown), "应得关闭确认帧:{response:?}"); + let result = handle.join().expect("server 线程应正常退出"); + assert!(result.is_ok(), "server 应正常退出:{result:?}"); + assert!(!config.sidecar_path.exists(), "sidecar 文件应被删除"); +}