diff --git a/Cargo.lock b/Cargo.lock index 37defc3b..c4e60977 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1160,7 +1160,7 @@ dependencies = [ [[package]] name = "oxide_builtins" -version = "0.8.36" +version = "0.8.37" dependencies = [ "chrono", "fast-float", @@ -1185,7 +1185,7 @@ dependencies = [ [[package]] name = "oxide_bytecode" -version = "0.8.36" +version = "0.8.37" dependencies = [ "num-bigint", "oxide_log", @@ -1193,7 +1193,7 @@ dependencies = [ [[package]] name = "oxide_cfg" -version = "0.8.36" +version = "0.8.37" dependencies = [ "oxide_bytecode", "oxide_emit", @@ -1204,7 +1204,7 @@ dependencies = [ [[package]] name = "oxide_cli" -version = "0.8.36" +version = "0.8.37" dependencies = [ "ansi_term", "bumpalo", @@ -1225,7 +1225,7 @@ dependencies = [ [[package]] name = "oxide_code_cache" -version = "0.8.36" +version = "0.8.37" dependencies = [ "lru", "oxide_bytecode", @@ -1234,7 +1234,7 @@ dependencies = [ [[package]] name = "oxide_compiler" -version = "0.8.36" +version = "0.8.37" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1253,7 +1253,7 @@ dependencies = [ [[package]] name = "oxide_dce" -version = "0.8.36" +version = "0.8.37" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1267,7 +1267,7 @@ dependencies = [ [[package]] name = "oxide_emit" -version = "0.8.36" +version = "0.8.37" dependencies = [ "num-bigint", "oxc_allocator", @@ -1282,7 +1282,7 @@ dependencies = [ [[package]] name = "oxide_ir" -version = "0.8.36" +version = "0.8.37" dependencies = [ "oxide_bytecode", "oxide_log", @@ -1291,7 +1291,7 @@ dependencies = [ [[package]] name = "oxide_kernel" -version = "0.8.36" +version = "0.8.37" dependencies = [ "bumpalo", "dashmap", @@ -1306,7 +1306,7 @@ dependencies = [ [[package]] name = "oxide_liveness" -version = "0.8.36" +version = "0.8.37" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1318,7 +1318,7 @@ dependencies = [ [[package]] name = "oxide_log" -version = "0.8.36" +version = "0.8.37" dependencies = [ "tracing", "tracing-appender", @@ -1327,7 +1327,7 @@ dependencies = [ [[package]] name = "oxide_parser" -version = "0.8.36" +version = "0.8.37" dependencies = [ "oxc_allocator", "oxc_ast", @@ -1342,7 +1342,7 @@ dependencies = [ [[package]] name = "oxide_regalloc" -version = "0.8.36" +version = "0.8.37" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1358,7 +1358,7 @@ dependencies = [ [[package]] name = "oxide_runtime_api" -version = "0.8.36" +version = "0.8.37" dependencies = [ "num-bigint", "num-traits", @@ -1370,7 +1370,7 @@ dependencies = [ [[package]] name = "oxide_test262" -version = "0.8.36" +version = "0.8.37" dependencies = [ "oxide_bytecode", "oxide_compiler", @@ -1387,7 +1387,7 @@ dependencies = [ [[package]] name = "oxide_types" -version = "0.8.36" +version = "0.8.37" dependencies = [ "bumpalo", "hashbrown 0.14.5", @@ -1399,7 +1399,7 @@ dependencies = [ [[package]] name = "oxide_vm" -version = "0.8.36" +version = "0.8.37" dependencies = [ "bumpalo", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 1bccace6..29f326e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ members = [ ] [workspace.package] -version = "0.8.36" +version = "0.8.37" 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 56ee4dd0..a4949651 100644 --- a/crates/oxide_cli/src/main.rs +++ b/crates/oxide_cli/src/main.rs @@ -10,6 +10,8 @@ use ansi_term::Colour::Red; use clap::{Parser, Subcommand}; use oxide_cli::format_js_value; 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_compiler::compiler::{compiled_module_hash, Compiler}; use oxide_compiler::compiler_error; @@ -204,15 +206,18 @@ fn main() -> ExitCode { } } -/// server 子命令分派:start(两形态)与 cleanup 为最小实现,其余八臂为 -/// not_implemented 占位。 +/// server 子命令分派:start(两形态)与 cleanup 为最小实现, +/// version/status/health/info/stop 五臂经控制客户端为真实实现, +/// 其余三臂为 not_implemented 占位。 /// /// # 步骤 /// 1. Start:rm 为假调持久 server 入口(well-known 路径),rm 为真调独立 /// 模式入口(进程唯一路径);`--workers` 为 Some 时覆盖 worker 数。 /// 2. Cleanup:调 well-known 路径清理入口,四态结果映射退出码(无残留与 /// 已清理退 0,拒绝与杀进程失败退 1)并打印结果。 -/// 3. 其余八臂:not_implemented 占位(退出码 2)。 +/// 3. 五个控制臂(version/status/health/info/stop):发对应控制请求, +/// 成功打印渲染文本退 0,失败打印错误消息退 1。 +/// 4. 其余三臂:not_implemented 占位(退出码 2)。 /// /// # 边界与前提 /// - `--idle-timeout` 不带 --rm 时静默忽略(语义门控归后续任务)。 @@ -221,6 +226,7 @@ fn main() -> ExitCode { /// # 副作用 /// - start 阻塞当前进程至 server 退出(持久形态为前台常驻)。 /// - cleanup 可能向 sidecar 记录的进程发 SIGTERM 并删除残留文件。 +/// - 控制臂向 server 建立并关闭一条 Unix socket 连接。 fn server_command(command: ServerCommands) -> ExitCode { match command { ServerCommands::Start { rm, idle_timeout, workers } => { @@ -259,17 +265,60 @@ fn server_command(command: ServerCommands) -> ExitCode { ExitCode::FAILURE } }, - ServerCommands::Stop => not_implemented("server stop"), - ServerCommands::Status => not_implemented("server status"), - ServerCommands::Health => not_implemented("server health"), - ServerCommands::Info => not_implemented("server info"), - ServerCommands::Version => not_implemented("server version"), + ServerCommands::Stop => control_arm(&ServerRequest::Shutdown, |response| match response { + ServerResponse::Shutdown => Some("server 正在关闭".to_string()), + _ => None, + }), + ServerCommands::Status => control_arm(&ServerRequest::Status, |response| match response { + ServerResponse::Status { + pool_available, + pool_total, + pool_peak, + uptime_ms, + } => Some(format!("池可用 {pool_available}/{pool_total},峰值 {pool_peak},运行 {uptime_ms} 毫秒")), + _ => None, + }), + ServerCommands::Health => control_arm(&ServerRequest::Health, |response| match response { + ServerResponse::Health { healthy } => Some(if *healthy { "healthy".into() } else { "unhealthy".into() }), + _ => None, + }), + ServerCommands::Info => control_arm(&ServerRequest::Info, |response| match response { + ServerResponse::Info { version, socket_path, pid } => { + Some(format!("版本 {version}\nsocket 路径 {socket_path}\n进程号 {pid}")) + } + _ => None, + }), + ServerCommands::Version => control_arm(&ServerRequest::Version, |response| match response { + ServerResponse::Version { version } => Some(version.clone()), + _ => None, + }), ServerCommands::Restart => not_implemented("server restart"), ServerCommands::Log => not_implemented("server log"), ServerCommands::Forge => not_implemented("server forge"), } } +/// 控制臂公共入口:发请求,成功打印渲染文本退 0,失败打印消息退 1。 +/// 渲染函数返回 None 表示响应变体与请求不匹配(防御性兜底)。 +fn control_arm(request: &ServerRequest, render: fn(&ServerResponse) -> Option) -> ExitCode { + match send_control_request(request) { + Ok(response) => match render(&response) { + Some(text) => { + println!("{text}"); + ExitCode::SUCCESS + } + None => { + eprintln!("{}", Red.paint(format!("意外的响应帧:{response:?}"))); + ExitCode::FAILURE + } + }, + Err(err) => { + eprintln!("{}", Red.paint(err.to_string())); + ExitCode::FAILURE + } + } +} + /// 启动结果到退出码:成功退 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 new file mode 100644 index 00000000..999b11e0 --- /dev/null +++ b/crates/oxide_cli/src/server/client.rs @@ -0,0 +1,262 @@ +//! 控制客户端:读 sidecar、连接 server、发一帧控制请求、读回一帧响应。 +//! +//! 关键约定: +//! - 连接超时 5 秒(安全网):本地 Unix socket 连接立即完成或立即失败, +//! 超时分支只覆盖 server 挂起等异常情形。 +//! - 读超时 5 秒(与交接路径的读超时同值):server 不回应即判连接失败, +//! 不挂死。 +//! - 错误三分支:连接失败、协议错误、server 错误帧,各映射到可读消息, +//! 不 panic。 + +use std::io::{BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::sync::mpsc::RecvTimeoutError; +use std::time::Duration; + +use super::protocol::{self, FrameReader, ProtocolError, ServerRequest, ServerResponse}; +use super::sidecar; + +/// 控制客户端超时:连接与读写各 5 秒(与交接路径的读超时同值)。 +pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); + +/// 控制客户端错误:三分支。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClientError { + /// 连接失败:sidecar 缺失或不可读、socket 缺失、连接被拒、5 秒超时、 + /// 写读 io 错误、server 未应答即关闭连接。 + Connect(String), + /// 协议错误:响应帧畸形(协议层的五变体原样包装)。 + Protocol(ProtocolError), + /// server 错误帧:server 以 Error 帧答复(如旧版本 server 不认新请求类型)。 + Server(String), +} + +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}"), + } + } +} + +/// 控制客户端入口:读 well-known sidecar,连接 server 并发一帧控制请求。 +pub fn send_control_request(request: &ServerRequest) -> Result { + send_control_request_from(&sidecar::well_known_sidecar_path(), request) +} + +/// 注入 sidecar 路径的入口(测试与复用):读 sidecar 取 socket 路径, +/// 连接、发请求、读响应,三分支错误映射。 +/// +/// # 步骤 +/// 1. 读 sidecar;缺失或损坏(解析失败)同归连接失败分支。 +/// 2. 连接 socket(5 秒超时安全网)。 +/// 3. 设读超时与写超时各 5 秒。 +/// 4. 写请求帧。 +/// 5. 读响应帧;io 错误或 server 未应答即关闭(EOF)判连接失败。 +/// 6. 解析响应帧;畸形判协议错误,Error 帧判 server 错误,其余返回。 +/// +/// # 边界与前提 +/// - sidecar 路径由调用方注入,生产入口用 well-known 路径。 +/// - 响应帧为 Error 变体时不视为成功,message 原样带出。 +/// +/// # 副作用 +/// - 建立并关闭一条 Unix socket 连接。 +pub fn send_control_request_from(sidecar_path: &Path, request: &ServerRequest) -> Result { + // 读 sidecar:缺失或损坏都判连接失败,消息提示先启动 server。 + let identity = sidecar::read_identity(sidecar_path).ok_or_else(|| { + ClientError::Connect(format!( + "无已注册的 server(sidecar 文件缺失或不可读:{}),请先用 `oxide server start` 启动 server", + sidecar_path.display() + )) + })?; + + // 连接:5 秒超时是安全网,本地 socket 连接立即完成或立即失败。 + let mut stream = connect_with_timeout(Path::new(&identity.socket_path), CONNECT_TIMEOUT)?; + + // 读写超时各 5 秒:server 挂起(不回应)时不无限阻塞。 + let _ = stream.set_read_timeout(Some(CONNECT_TIMEOUT)); + let _ = stream.set_write_timeout(Some(CONNECT_TIMEOUT)); + + // 写请求帧(含结尾换行)。 + stream + .write_all(protocol::encode_request(request).as_bytes()) + .map_err(|e| ClientError::Connect(format!("写请求帧失败:{e}")))?; + + // 读响应帧:io 错误判连接失败,EOF(server 未应答即关闭)判连接失败。 + let mut reader = FrameReader::new(BufReader::new(stream)); + let frame = reader + .read_frame() + .map_err(|e| ClientError::Connect(format!("读响应帧失败:{e}")))? + .ok_or_else(|| ClientError::Connect("server 未应答即关闭连接".into()))?; + + // 解析响应帧:畸形判协议错误,Error 帧判 server 错误,其余返回。 + let response = protocol::parse_response(&frame).map_err(ClientError::Protocol)?; + match response { + ServerResponse::Error { message } => Err(ClientError::Server(message)), + other => Ok(other), + } +} + +/// 带超时的连接:线程内连接、通道上带超时取结果。 +/// +/// # 步骤 +/// 1. 派生线程连接 socket。 +/// 2. 通道上带超时取结果;超时判连接失败。 +/// +/// # 边界与前提 +/// - 超时后连接线程继续存活到连接完成或失败(本地 socket 立即出结果), +/// 发送端已被接收端丢弃,线程随即退出,无泄漏。 +fn connect_with_timeout(path: &Path, timeout: Duration) -> Result { + let (tx, rx) = std::sync::mpsc::channel(); + let spawn_path = path.to_path_buf(); + let display = path.display().to_string(); + std::thread::spawn(move || { + let _ = tx.send(UnixStream::connect(&spawn_path)); + }); + match rx.recv_timeout(timeout) { + Ok(Ok(stream)) => Ok(stream), + Ok(Err(e)) => Err(ClientError::Connect(format!( + "连接 server 失败:{e}(socket 路径 {display},可用 `oxide server cleanup` 清理陈旧文件)" + ))), + Err(RecvTimeoutError::Timeout) => Err(ClientError::Connect(format!( + "连接 server 超时({} 秒),socket 路径 {display}", + timeout.as_secs() + ))), + Err(RecvTimeoutError::Disconnected) => Err(ClientError::Connect("连接线程异常退出".into())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::os::unix::net::UnixListener; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + /// temp_dir 下的唯一临时目录,退出时自动删除。 + struct TestDir(PathBuf); + + impl TestDir { + fn new() -> Self { + let ns = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("oxide_client_test_{}_{}", std::process::id(), ns)); + fs::create_dir_all(&dir).expect("测试目录创建失败"); + TestDir(dir) + } + + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + /// 假 server 助手:绑定监听器,派生线程接受一个连接并执行给定处理。 + fn fake_server(socket: &Path, respond: impl FnOnce(&mut UnixStream) + Send + 'static) { + let listener = UnixListener::bind(socket).expect("绑定假 server 应成功"); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + respond(&mut stream); + } + }); + } + + /// 写一个指向给定 socket 路径的合法 sidecar。 + fn write_sidecar(sidecar: &Path, socket: &Path) { + let identity = sidecar::ServerIdentity::new(socket.to_str().unwrap(), "0.0.0"); + sidecar::write_exclusive(&identity, sidecar).expect("写 sidecar 应成功"); + } + + /// 无 sidecar:判连接失败分支。 + #[test] + fn no_sidecar_is_connect_branch() { + let dir = TestDir::new(); + let sidecar = dir.path("sidecar.json"); + let err = send_control_request_from(&sidecar, &ServerRequest::Version).unwrap_err(); + assert!(matches!(err, ClientError::Connect(_)), "应判连接失败:{err:?}"); + } + + /// 损坏 sidecar(非 JSON):判连接失败分支。 + #[test] + fn corrupt_sidecar_is_connect_branch() { + let dir = TestDir::new(); + let sidecar = dir.path("sidecar.json"); + fs::write(&sidecar, "not json at all").expect("写损坏文件应成功"); + let err = send_control_request_from(&sidecar, &ServerRequest::Version).unwrap_err(); + assert!(matches!(err, ClientError::Connect(_)), "应判连接失败:{err:?}"); + } + + /// sidecar 合法但 socket 无监听者:连接被拒判连接失败分支。 + #[test] + fn socket_missing_is_connect_branch() { + let dir = TestDir::new(); + let sidecar = dir.path("sidecar.json"); + let socket = dir.path("server.sock"); + write_sidecar(&sidecar, &socket); + let err = send_control_request_from(&sidecar, &ServerRequest::Version).unwrap_err(); + assert!(matches!(err, ClientError::Connect(_)), "应判连接失败:{err:?}"); + } + + /// 假 server 回非 JSON:判协议错误分支。 + #[test] + fn garbage_response_is_protocol_branch() { + let dir = TestDir::new(); + let sidecar = dir.path("sidecar.json"); + let socket = dir.path("server.sock"); + write_sidecar(&sidecar, &socket); + fake_server(&socket, |stream| { + let mut reader = FrameReader::new(BufReader::new(stream.try_clone().expect("克隆流应成功"))); + let _ = reader.read_frame(); + let _ = stream.write_all(b"not json\n"); + }); + let err = send_control_request_from(&sidecar, &ServerRequest::Version).unwrap_err(); + assert!(matches!(err, ClientError::Protocol(_)), "应判协议错误:{err:?}"); + } + + /// 假 server 回 Error 帧:判 server 错误分支且 message 原样带出。 + #[test] + fn error_frame_is_server_branch() { + let dir = TestDir::new(); + let sidecar = dir.path("sidecar.json"); + let socket = dir.path("server.sock"); + write_sidecar(&sidecar, &socket); + fake_server(&socket, |stream| { + let mut reader = FrameReader::new(BufReader::new(stream.try_clone().expect("克隆流应成功"))); + let _ = reader.read_frame(); + let frame = protocol::encode_response(&ServerResponse::Error { + message: "server 内部错误".into(), + }); + let _ = stream.write_all(frame.as_bytes()); + }); + let err = send_control_request_from(&sidecar, &ServerRequest::Version).unwrap_err(); + assert_eq!(err, ClientError::Server("server 内部错误".into()), "message 应原样带出:{err:?}"); + } + + /// 假 server 接受后不回应:5 秒读超时后判连接失败分支。 + #[test] + fn read_timeout_is_connect_branch() { + let dir = TestDir::new(); + let sidecar = dir.path("sidecar.json"); + let socket = dir.path("server.sock"); + write_sidecar(&sidecar, &socket); + fake_server(&socket, |stream| { + let mut reader = FrameReader::new(BufReader::new(stream.try_clone().expect("克隆流应成功"))); + let _ = reader.read_frame(); + // 不回应:保持连接打开,客户端应命中 5 秒读超时。 + std::thread::sleep(Duration::from_secs(10)); + }); + let started = std::time::Instant::now(); + let err = send_control_request_from(&sidecar, &ServerRequest::Version).unwrap_err(); + let elapsed = started.elapsed(); + assert!(matches!(err, ClientError::Connect(_)), "应判连接失败:{err:?}"); + assert!(elapsed >= Duration::from_secs(5), "应命中 5 秒读超时:{elapsed:?}"); + } +} diff --git a/crates/oxide_cli/src/server/mod.rs b/crates/oxide_cli/src/server/mod.rs index d1b1d6a1..da6db474 100644 --- a/crates/oxide_cli/src/server/mod.rs +++ b/crates/oxide_cli/src/server/mod.rs @@ -2,14 +2,17 @@ //! //! 持久 server 是常驻进程,复用预热池、近零 spawn 成本; //! 通信走 Unix socket + NDJSON(换行分隔 JSON)。 -//! 模块分五层:协议数据面(帧格式与 serde 结构体)、身份注册(sidecar 文件 -//! 与 liveness 检查原语)、执行请求路径(parse → compile → spawn → run → -//! format → drop 六段纯函数)、worker 池(固定 N 常驻 worker、各持自有 VM -//! 池、mpsc 轮询路由)、server 进程主体(accept 循环、控制请求直接处理、 -//! 执行请求路由、优雅退出)。 +//! 模块分六层:协议数据面(帧格式与 serde 结构体)、身份注册(sidecar 文件 +//! 与 liveness 检查原语)、控制客户端(读 sidecar、连接 server、发控制请求 +//! 帧、读回响应帧、三分支错误)、执行请求路径(parse → compile → spawn → +//! run → format → drop 六段纯函数)、worker 池(固定 N 常驻 worker、各持 +//! 自有 VM 池、mpsc 轮询路由)、server 进程主体(accept 循环、控制请求直接 +//! 处理、执行请求路由、优雅退出)。 /// 人工兜底清理命令:扫 sidecar、探活、杀 PID、删孤儿文件。 pub mod cleanup; +/// 控制客户端:读 sidecar、连接 server、发一帧控制请求、读回一帧响应。 +pub mod client; /// 执行请求路径:parse → compile → spawn → run → format → drop 六段纯函数。 pub mod eval; /// 协议数据面:NDJSON 帧格式与 serde 结构体。 diff --git a/crates/oxide_cli/tests/server_cli.rs b/crates/oxide_cli/tests/server_cli.rs index 85b78cd2..328873b8 100644 --- a/crates/oxide_cli/tests/server_cli.rs +++ b/crates/oxide_cli/tests/server_cli.rs @@ -48,10 +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 仍是占位。 #[test] fn server_stub_subcommands_exit_2() { - for sub in ["stop", "status", "health", "info", "version", "restart", "log", "forge"] { + for sub in ["restart", "log", "forge"] { let output = oxide(&["server", sub]); assert_eq!(output.status.code(), Some(2), "{sub} 应退 2"); let stderr = String::from_utf8_lossy(&output.stderr); @@ -59,6 +62,28 @@ fn server_stub_subcommands_exit_2() { } } +/// 五个控制臂无 server 时各退 1,stderr 含「无已注册的 server」提示。 +/// +/// 走 well-known 全局路径:先探活,存活 server 占用时 panic 不抢占。 +#[test] +fn server_control_arms_no_server_exit_1() { + let _guard = WELL_KNOWN_LOCK.lock().expect("全局路径锁不应中毒"); + assert!( + !sidecar::is_server_alive(&sidecar::well_known_socket_path()), + "存活 server 占用 well-known 路径,测试不抢占" + ); + + for sub in ["stop", "status", "health", "info", "version"] { + let output = oxide(&["server", sub]); + assert_eq!(output.status.code(), Some(1), "{sub} 应退 1"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("无已注册的 server"), + "{sub} 应打印无 server 提示:{stderr}" + ); + } +} + /// cleanup 退 0(幂等:有残留则清理、无残留则报无残留),事后全局路径无文件。 #[test] fn server_cleanup_exit_0() { diff --git a/crates/oxide_cli/tests/server_client.rs b/crates/oxide_cli/tests/server_client.rs new file mode 100644 index 00000000..7e7de720 --- /dev/null +++ b/crates/oxide_cli/tests/server_client.rs @@ -0,0 +1,102 @@ +//! server 控制客户端集成测试:真实 run_server 加五类请求响应加关闭确认加 +//! 文件删除断言。 +//! +//! 只用临时路径(经 `send_control_request_from` 注入),不触碰 well-known +//! 全局单例。 + +use std::fs; +use std::time::{Duration, Instant}; + +use oxide_cli::server::client::send_control_request_from; +use oxide_cli::server::protocol::{ServerRequest, ServerResponse}; +use oxide_cli::server::server::{run_server, ServerConfig}; + +/// 临时路径配置:进程号加测试名唯一,worker 数 2,版本取构建期版本。 +fn test_config(test_name: &str) -> ServerConfig { + let dir = std::env::temp_dir().join(format!("oxide_server_client_test_{}_{}", std::process::id(), test_name)); + fs::create_dir_all(&dir).expect("测试目录创建应成功"); + ServerConfig { + socket_path: dir.join("server.sock"), + sidecar_path: dir.join("sidecar.json"), + worker_count: 2, + version: env!("CARGO_PKG_VERSION").to_string(), + } +} + +/// 后台线程起真实 run_server,等 socket 文件出现(bind 成功即就绪)。 +fn start_server(config: ServerConfig) -> std::thread::JoinHandle> { + let server_config = config.clone(); + let handle = std::thread::spawn(move || run_server(&server_config)); + let deadline = Instant::now() + Duration::from_secs(10); + while !config.socket_path.exists() { + assert!(Instant::now() < deadline, "socket 文件未出现"); + std::thread::sleep(Duration::from_millis(10)); + } + handle +} + +/// 端到端:真实 server 依次应答五类控制请求,关闭确认后线程退出、文件被删。 +#[test] +fn control_client_end_to_end() { + let config = test_config("e2e"); + let handle = start_server(config.clone()); + + // 版本请求:版本与构建期版本一致。 + 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:?}"), + } + + // 状态请求:worker 池异步预热,轮询至总数不小于 1 后核对字段。 + let deadline = Instant::now() + Duration::from_secs(5); + let response = loop { + let response = + send_control_request_from(&config.sidecar_path, &ServerRequest::Status).expect("状态请求应成功"); + if let ServerResponse::Status { pool_total, .. } = &response { + if *pool_total >= 1 { + break response; + } + } + assert!(Instant::now() < deadline, "worker 池预热应完成"); + std::thread::sleep(Duration::from_millis(10)); + }; + match response { + ServerResponse::Status { pool_available, pool_total, .. } => { + assert!(pool_available <= pool_total, "可用数应不超过总数:{pool_available}/{pool_total}"); + assert!(pool_total >= 1, "总数应不小于 1:{pool_total}"); + } + other => panic!("应得 Status 帧,实得 {other:?}"), + } + + // 健康请求:healthy 为真。 + let response = send_control_request_from(&config.sidecar_path, &ServerRequest::Health).expect("健康请求应成功"); + assert!( + matches!(response, ServerResponse::Health { healthy: true }), + "healthy 应为真:{response:?}" + ); + + // 信息请求:socket 路径与配置一致、进程号为当前进程号。 + let response = send_control_request_from(&config.sidecar_path, &ServerRequest::Info).expect("信息请求应成功"); + match response { + ServerResponse::Info { socket_path, pid, .. } => { + assert_eq!(socket_path, config.socket_path.to_string_lossy().into_owned(), "socket 路径应与配置一致"); + assert_eq!(pid, std::process::id(), "进程号应为当前进程号"); + } + other => panic!("应得 Info 帧,实得 {other:?}"), + } + + // 关闭请求:得确认帧,server 线程正常退出,socket 与 sidecar 文件均被删。 + 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.socket_path.exists(), "socket 文件应被删除"); + assert!(!config.sidecar_path.exists(), "sidecar 文件应被删除"); +}