diff --git a/Cargo.lock b/Cargo.lock index c4e60977..ac258107 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1160,7 +1160,7 @@ dependencies = [ [[package]] name = "oxide_builtins" -version = "0.8.37" +version = "0.8.38" dependencies = [ "chrono", "fast-float", @@ -1185,7 +1185,7 @@ dependencies = [ [[package]] name = "oxide_bytecode" -version = "0.8.37" +version = "0.8.38" dependencies = [ "num-bigint", "oxide_log", @@ -1193,7 +1193,7 @@ dependencies = [ [[package]] name = "oxide_cfg" -version = "0.8.37" +version = "0.8.38" dependencies = [ "oxide_bytecode", "oxide_emit", @@ -1204,7 +1204,7 @@ dependencies = [ [[package]] name = "oxide_cli" -version = "0.8.37" +version = "0.8.38" dependencies = [ "ansi_term", "bumpalo", @@ -1225,7 +1225,7 @@ dependencies = [ [[package]] name = "oxide_code_cache" -version = "0.8.37" +version = "0.8.38" dependencies = [ "lru", "oxide_bytecode", @@ -1234,7 +1234,7 @@ dependencies = [ [[package]] name = "oxide_compiler" -version = "0.8.37" +version = "0.8.38" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1253,7 +1253,7 @@ dependencies = [ [[package]] name = "oxide_dce" -version = "0.8.37" +version = "0.8.38" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1267,7 +1267,7 @@ dependencies = [ [[package]] name = "oxide_emit" -version = "0.8.37" +version = "0.8.38" dependencies = [ "num-bigint", "oxc_allocator", @@ -1282,7 +1282,7 @@ dependencies = [ [[package]] name = "oxide_ir" -version = "0.8.37" +version = "0.8.38" dependencies = [ "oxide_bytecode", "oxide_log", @@ -1291,7 +1291,7 @@ dependencies = [ [[package]] name = "oxide_kernel" -version = "0.8.37" +version = "0.8.38" dependencies = [ "bumpalo", "dashmap", @@ -1306,7 +1306,7 @@ dependencies = [ [[package]] name = "oxide_liveness" -version = "0.8.37" +version = "0.8.38" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1318,7 +1318,7 @@ dependencies = [ [[package]] name = "oxide_log" -version = "0.8.37" +version = "0.8.38" dependencies = [ "tracing", "tracing-appender", @@ -1327,7 +1327,7 @@ dependencies = [ [[package]] name = "oxide_parser" -version = "0.8.37" +version = "0.8.38" dependencies = [ "oxc_allocator", "oxc_ast", @@ -1342,7 +1342,7 @@ dependencies = [ [[package]] name = "oxide_regalloc" -version = "0.8.37" +version = "0.8.38" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1358,7 +1358,7 @@ dependencies = [ [[package]] name = "oxide_runtime_api" -version = "0.8.37" +version = "0.8.38" dependencies = [ "num-bigint", "num-traits", @@ -1370,7 +1370,7 @@ dependencies = [ [[package]] name = "oxide_test262" -version = "0.8.37" +version = "0.8.38" dependencies = [ "oxide_bytecode", "oxide_compiler", @@ -1387,7 +1387,7 @@ dependencies = [ [[package]] name = "oxide_types" -version = "0.8.37" +version = "0.8.38" dependencies = [ "bumpalo", "hashbrown 0.14.5", @@ -1399,7 +1399,7 @@ dependencies = [ [[package]] name = "oxide_vm" -version = "0.8.37" +version = "0.8.38" dependencies = [ "bumpalo", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 29f326e3..d1341978 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ members = [ ] [workspace.package] -version = "0.8.37" +version = "0.8.38" 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 a4949651..e326e2f4 100644 --- a/crates/oxide_cli/src/main.rs +++ b/crates/oxide_cli/src/main.rs @@ -2,7 +2,7 @@ #![allow(dead_code)] use std::fs; -use std::process::ExitCode; +use std::process::{Command, ExitCode, Stdio}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -13,6 +13,7 @@ use oxide_cli::server::cleanup::{well_known_cleanup, CleanupOutcome}; use oxide_cli::server::client::send_control_request; use oxide_cli::server::protocol::{ServerRequest, ServerResponse}; use oxide_cli::server::server::{run_server, run_server_rm, RmServerConfig, ServerConfig}; +use oxide_cli::server::sidecar; use oxide_compiler::compiler::{compiled_module_hash, Compiler}; use oxide_compiler::compiler_error; use oxide_kernel::kernel::{KernelConfig, KernelCore}; @@ -134,7 +135,7 @@ enum ServerCommands { Version, /// 清理残留文件(无 sidecar、陈旧 socket、杀刻度匹配进程)。 Cleanup, - /// 向持久 server 发送 yield 请求(排空退出后重新接管 socket 路径)。 + /// 向存活 server 发关闭请求,等其退出后拉起新 server 进程。 Restart, /// 查看 server 日志。 Log, @@ -207,8 +208,8 @@ fn main() -> ExitCode { } /// server 子命令分派:start(两形态)与 cleanup 为最小实现, -/// version/status/health/info/stop 五臂经控制客户端为真实实现, -/// 其余三臂为 not_implemented 占位。 +/// version/status/health/info/stop/restart 六臂为真实实现, +/// 其余两臂为 not_implemented 占位。 /// /// # 步骤 /// 1. Start:rm 为假调持久 server 入口(well-known 路径),rm 为真调独立 @@ -217,7 +218,9 @@ fn main() -> ExitCode { /// 已清理退 0,拒绝与杀进程失败退 1)并打印结果。 /// 3. 五个控制臂(version/status/health/info/stop):发对应控制请求, /// 成功打印渲染文本退 0,失败打印错误消息退 1。 -/// 4. 其余三臂:not_implemented 占位(退出码 2)。 +/// 4. Restart:发关闭请求等旧 server 退出,拉起新 server 进程并探测就绪 +/// (详见 restart_command)。 +/// 5. 其余两臂:not_implemented 占位(退出码 2)。 /// /// # 边界与前提 /// - `--idle-timeout` 不带 --rm 时静默忽略(语义门控归后续任务)。 @@ -266,7 +269,7 @@ fn server_command(command: ServerCommands) -> ExitCode { } }, ServerCommands::Stop => control_arm(&ServerRequest::Shutdown, |response| match response { - ServerResponse::Shutdown => Some("server 正在关闭".to_string()), + ServerResponse::Shutdown => Some("已停止".to_string()), _ => None, }), ServerCommands::Status => control_arm(&ServerRequest::Status, |response| match response { @@ -284,7 +287,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}\nsocket:{socket_path}\n进程号:{pid}")) } _ => None, }), @@ -292,7 +295,7 @@ fn server_command(command: ServerCommands) -> ExitCode { ServerResponse::Version { version } => Some(version.clone()), _ => None, }), - ServerCommands::Restart => not_implemented("server restart"), + ServerCommands::Restart => restart_command(), ServerCommands::Log => not_implemented("server log"), ServerCommands::Forge => not_implemented("server forge"), } @@ -319,6 +322,81 @@ fn control_arm(request: &ServerRequest, render: fn(&ServerResponse) -> Option ExitCode { + // 发关闭请求:三分支错误都以红色打印退 1,无 server 时消息即预设不满足提示。 + if let Err(err) = send_control_request(&ServerRequest::Shutdown) { + eprintln!("{}", Red.paint(err.to_string())); + return ExitCode::FAILURE; + } + + // 等旧 server 退出:sidecar 是退出序列的最后删除项,消失即排水完成。 + let sidecar_path = sidecar::well_known_sidecar_path(); + let deadline = Instant::now() + Duration::from_secs(10); + while sidecar_path.exists() { + if Instant::now() >= deadline { + eprintln!("{}", Red.paint("server 未在 10 秒内退出,可用 `oxide server cleanup` 人工检查")); + return ExitCode::FAILURE; + } + std::thread::sleep(Duration::from_millis(50)); + } + + // 拉起新 server:脱离进程,标准流全空,不等待。 + let exe = match std::env::current_exe() { + Ok(path) => path, + Err(err) => { + eprintln!("{}", Red.paint(format!("获取当前可执行文件路径失败:{err}"))); + return ExitCode::FAILURE; + } + }; + if let Err(err) = Command::new(exe) + .args(["server", "start"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + eprintln!("{}", Red.paint(format!("拉起新 server 失败:{err}"))); + return ExitCode::FAILURE; + } + + // 就绪探测:健康请求通过即新 server 的监听与 accept 循环在运行。 + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let ready = + matches!(send_control_request(&ServerRequest::Health), Ok(ServerResponse::Health { healthy: true })); + if ready { + break; + } + if Instant::now() >= deadline { + eprintln!("{}", Red.paint("新 server 未在 10 秒内就绪")); + return ExitCode::FAILURE; + } + std::thread::sleep(Duration::from_millis(100)); + } + + println!("已重启"); + ExitCode::SUCCESS +} + /// 启动结果到退出码:成功退 0,错误以红色打印到 stderr 后退 1。 fn start_result(result: Result<(), oxide_cli::server::server::ServerError>) -> ExitCode { match result { diff --git a/crates/oxide_cli/src/server/client.rs b/crates/oxide_cli/src/server/client.rs index 999b11e0..ec0d6868 100644 --- a/crates/oxide_cli/src/server/client.rs +++ b/crates/oxide_cli/src/server/client.rs @@ -36,8 +36,10 @@ impl std::fmt::Display for ClientError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ClientError::Connect(msg) => write!(f, "{msg}"), - ClientError::Protocol(err) => write!(f, "响应帧畸形:{err}"), - ClientError::Server(msg) => write!(f, "server 以错误帧答复:{msg}"), + ClientError::Protocol(err) => { + write!(f, "响应帧畸形:{err},server 版本可能不匹配,可用 `oxide server restart` 更新") + } + ClientError::Server(msg) => write!(f, "server 拒绝请求:{msg}"), } } } diff --git a/crates/oxide_cli/tests/server_cli.rs b/crates/oxide_cli/tests/server_cli.rs index 328873b8..9563bf4f 100644 --- a/crates/oxide_cli/tests/server_cli.rs +++ b/crates/oxide_cli/tests/server_cli.rs @@ -1,9 +1,9 @@ //! server CLI 集成测试:真实二进制的分派面(help 列子命令、占位臂退出码、 -//! cleanup 幂等、start 端到端)。 +//! cleanup 幂等、start 与 restart 端到端)。 //! -//! start 端到端与 cleanup 走 well-known 全局路径(每用户单例):测试先探活, -//! socket 存活即 panic 不抢占存活 server;两枚触碰全局路径的测试经同一把 -//! 锁串行,避免相互干扰。 +//! start / restart 端到端与 cleanup 走 well-known 全局路径(每用户单例): +//! 测试先探活,socket 存活即 panic 不抢占存活 server;三枚触碰全局路径的 +//! 测试经同一把锁串行,避免相互干扰。 use std::io::{BufReader, Write}; use std::os::unix::net::UnixStream; @@ -48,13 +48,13 @@ fn server_help_lists_subcommands() { } } -/// 三个占位臂各退 2,stderr 含 not yet implemented。 +/// 两个占位臂各退 2,stderr 含 not yet implemented。 /// -/// version/status/health/info/stop 五臂已由控制客户端接管(无 server 时退 1), -/// 不在此列;restart/log/forge 仍是占位。 +/// version/status/health/info/stop/restart 六臂已是真实实现(无 server 时退 1), +/// 不在此列;log/forge 仍是占位。 #[test] fn server_stub_subcommands_exit_2() { - for sub in ["restart", "log", "forge"] { + for sub in ["log", "forge"] { let output = oxide(&["server", sub]); assert_eq!(output.status.code(), Some(2), "{sub} 应退 2"); let stderr = String::from_utf8_lossy(&output.stderr); @@ -157,3 +157,82 @@ fn server_start_e2e() { assert!(!socket.exists(), "socket 文件应被删除"); assert!(!sidecar_path.exists(), "sidecar 文件应被删除"); } + +/// restart 端到端:起真实子进程 A,跑 `oxide server restart`(发关闭、等退出、 +/// 拉起新 server、就绪探测),断言退 0 且打印「已重启」,旧进程退 0,新 sidecar +/// 进程号不同且版本为构建期版本,新 server 健康与版本请求正常,收尾关闭后文件被删。 +#[test] +fn server_restart_e2e() { + let _guard = WELL_KNOWN_LOCK.lock().expect("全局路径锁不应中毒"); + let socket = sidecar::well_known_socket_path(); + let sidecar_path = sidecar::well_known_sidecar_path(); + + // 探活先行:存活 server 占用全局路径时 panic,不抢占。 + assert!(!sidecar::is_server_alive(&socket), "存活 server 占用 well-known 路径,测试不抢占"); + + // 起子进程 A:worker 数压到 2,轮询 socket 文件出现(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 文件未出现"); + std::thread::sleep(Duration::from_millis(10)); + } + + // 读 sidecar 取旧进程号。 + let old_id = sidecar::read_identity(&sidecar_path).expect("读旧 sidecar 应成功"); + + // 跑 restart:断言退 0、stdout 含「已重启」。 + let output = oxide(&["server", "restart"]); + assert_eq!(output.status.code(), Some(0), "restart 应退 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("已重启"), "restart 应打印「已重启」:{stdout}"); + + // 断言子进程 A 以退出码 0 退出(30 秒截止,try_wait 轮询)。 + let deadline = Instant::now() + Duration::from_secs(30); + loop { + match child.try_wait() { + Ok(Some(status)) => { + assert_eq!(status.code(), Some(0), "旧 server 应以退出码 0 退出:{status:?}"); + break; + } + Ok(None) => { + assert!(Instant::now() < deadline, "旧 server 应在有界时间内退出"); + std::thread::sleep(Duration::from_millis(50)); + } + Err(err) => panic!("等待子进程失败:{err}"), + } + } + + // 断言新 sidecar 存在、进程号与旧进程号不同、版本等于构建期版本。 + let new_id = sidecar::read_identity(&sidecar_path).expect("读新 sidecar 应成功"); + assert_ne!(new_id.pid, old_id.pid, "新 server 进程号应不同于旧 server"); + assert_eq!(new_id.version, env!("CARGO_PKG_VERSION"), "新 sidecar 版本应与构建期版本一致"); + + // 连接新 server:健康请求得 healthy,版本请求得构建期版本。 + let mut stream = UnixStream::connect(&socket).expect("连接新 server 应成功"); + match send_and_recv(&mut stream, &ServerRequest::Health) { + ServerResponse::Health { healthy } => assert!(healthy, "新 server 应健康"), + other => panic!("应得 Health 帧,实得 {other:?}"), + } + match send_and_recv(&mut stream, &ServerRequest::Version) { + ServerResponse::Version { version } => { + assert_eq!(version, env!("CARGO_PKG_VERSION"), "版本应与构建期版本一致"); + } + other => panic!("应得 Version 帧,实得 {other:?}"), + } + + // 收尾:发关闭请求,等新 server 退出,断言 socket 与 sidecar 文件被删。 + let response = send_and_recv(&mut stream, &ServerRequest::Shutdown); + assert!(matches!(response, ServerResponse::Shutdown), "应得关闭确认帧:{response:?}"); + drop(stream); + let deadline = Instant::now() + Duration::from_secs(30); + while sidecar_path.exists() { + assert!(Instant::now() < deadline, "新 server 应在有界时间内退出"); + std::thread::sleep(Duration::from_millis(50)); + } + assert!(!socket.exists(), "socket 文件应被删除"); + assert!(!sidecar_path.exists(), "sidecar 文件应被删除"); +}