From 79e94dd00391c759ac8cff4e129cc37c8ba1f822 Mon Sep 17 00:00:00 2001 From: pjh456 <147148383@qq.com> Date: Tue, 6 Oct 2026 17:23:25 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20server=20CLI=20=E9=AA=A8=E6=9E=B6?= =?UTF-8?q?=EF=BC=8CServer=20=E5=AD=90=E5=91=BD=E4=BB=A4=E5=8D=81=E5=8F=98?= =?UTF-8?q?=E4=BD=93=E5=88=86=E6=B4=BE=E5=8A=A0=20start=20=E4=B8=8E=20clea?= =?UTF-8?q?nup=20=E6=9C=80=E5=B0=8F=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 36 +++---- Cargo.toml | 2 +- crates/oxide_cli/src/main.rs | 125 ++++++++++++++++++++++++- crates/oxide_cli/tests/server_cli.rs | 134 +++++++++++++++++++++++++++ 4 files changed, 276 insertions(+), 21 deletions(-) create mode 100644 crates/oxide_cli/tests/server_cli.rs diff --git a/Cargo.lock b/Cargo.lock index 2498d27c..37defc3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1160,7 +1160,7 @@ dependencies = [ [[package]] name = "oxide_builtins" -version = "0.8.35" +version = "0.8.36" dependencies = [ "chrono", "fast-float", @@ -1185,7 +1185,7 @@ dependencies = [ [[package]] name = "oxide_bytecode" -version = "0.8.35" +version = "0.8.36" dependencies = [ "num-bigint", "oxide_log", @@ -1193,7 +1193,7 @@ dependencies = [ [[package]] name = "oxide_cfg" -version = "0.8.35" +version = "0.8.36" dependencies = [ "oxide_bytecode", "oxide_emit", @@ -1204,7 +1204,7 @@ dependencies = [ [[package]] name = "oxide_cli" -version = "0.8.35" +version = "0.8.36" dependencies = [ "ansi_term", "bumpalo", @@ -1225,7 +1225,7 @@ dependencies = [ [[package]] name = "oxide_code_cache" -version = "0.8.35" +version = "0.8.36" dependencies = [ "lru", "oxide_bytecode", @@ -1234,7 +1234,7 @@ dependencies = [ [[package]] name = "oxide_compiler" -version = "0.8.35" +version = "0.8.36" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1253,7 +1253,7 @@ dependencies = [ [[package]] name = "oxide_dce" -version = "0.8.35" +version = "0.8.36" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1267,7 +1267,7 @@ dependencies = [ [[package]] name = "oxide_emit" -version = "0.8.35" +version = "0.8.36" dependencies = [ "num-bigint", "oxc_allocator", @@ -1282,7 +1282,7 @@ dependencies = [ [[package]] name = "oxide_ir" -version = "0.8.35" +version = "0.8.36" dependencies = [ "oxide_bytecode", "oxide_log", @@ -1291,7 +1291,7 @@ dependencies = [ [[package]] name = "oxide_kernel" -version = "0.8.35" +version = "0.8.36" dependencies = [ "bumpalo", "dashmap", @@ -1306,7 +1306,7 @@ dependencies = [ [[package]] name = "oxide_liveness" -version = "0.8.35" +version = "0.8.36" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1318,7 +1318,7 @@ dependencies = [ [[package]] name = "oxide_log" -version = "0.8.35" +version = "0.8.36" dependencies = [ "tracing", "tracing-appender", @@ -1327,7 +1327,7 @@ dependencies = [ [[package]] name = "oxide_parser" -version = "0.8.35" +version = "0.8.36" dependencies = [ "oxc_allocator", "oxc_ast", @@ -1342,7 +1342,7 @@ dependencies = [ [[package]] name = "oxide_regalloc" -version = "0.8.35" +version = "0.8.36" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1358,7 +1358,7 @@ dependencies = [ [[package]] name = "oxide_runtime_api" -version = "0.8.35" +version = "0.8.36" dependencies = [ "num-bigint", "num-traits", @@ -1370,7 +1370,7 @@ dependencies = [ [[package]] name = "oxide_test262" -version = "0.8.35" +version = "0.8.36" dependencies = [ "oxide_bytecode", "oxide_compiler", @@ -1387,7 +1387,7 @@ dependencies = [ [[package]] name = "oxide_types" -version = "0.8.35" +version = "0.8.36" dependencies = [ "bumpalo", "hashbrown 0.14.5", @@ -1399,7 +1399,7 @@ dependencies = [ [[package]] name = "oxide_vm" -version = "0.8.35" +version = "0.8.36" dependencies = [ "bumpalo", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 999b3221..1bccace6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ members = [ ] [workspace.package] -version = "0.8.35" +version = "0.8.36" 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 96ef3757..56ee4dd0 100644 --- a/crates/oxide_cli/src/main.rs +++ b/crates/oxide_cli/src/main.rs @@ -4,13 +4,15 @@ use std::fs; use std::process::ExitCode; use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; 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::server::{run_server, run_server_rm, RmServerConfig, ServerConfig}; use oxide_compiler::compiler::{compiled_module_hash, Compiler}; use oxide_compiler::compiler_error; -use oxide_cli::format_js_value; use oxide_kernel::kernel::{KernelConfig, KernelCore}; use oxide_kernel::shape_forge::ShapeForge; use oxide_kernel::string_forge::PermInterner; @@ -95,6 +97,47 @@ enum Commands { Test { suite: Option, }, + /// 持久 server:常驻进程,复用预热池,近零 spawn 成本。 + Server { + #[command(subcommand)] + command: ServerCommands, + }, +} + +/// server 子命令:持久 server 进程管理与 --rm 独立模式。 +#[derive(Subcommand)] +enum ServerCommands { + /// 启动 server。持久形态前台常驻(阻塞至关闭请求、信号或 yield 触发退出); + /// --rm 为独立形态(进程唯一 socket、不注册 sidecar、空闲超时或断开即退出)。 + Start { + /// 独立模式:进程唯一 socket 路径,不注册 sidecar,空闲超时或断开即自动退出。 + #[arg(long)] + rm: bool, + /// 空闲超时(秒),仅对 --rm 有效;窗口内无连接或连接上无数据即自动退出。 + #[arg(long, default_value = "30")] + idle_timeout: u64, + /// 常驻 worker 线程数;缺省取宿主核数。 + #[arg(long)] + workers: Option, + }, + /// 向持久 server 发送关闭请求。 + Stop, + /// 查询 server 状态(池与运行时长)。 + Status, + /// 健康检查。 + Health, + /// 查询 server 信息(版本、socket 路径、进程号)。 + Info, + /// 查询 server 版本。 + Version, + /// 清理残留文件(无 sidecar、陈旧 socket、杀刻度匹配进程)。 + Cleanup, + /// 向持久 server 发送 yield 请求(排空退出后重新接管 socket 路径)。 + Restart, + /// 查看 server 日志。 + Log, + /// 查询内部状态(代码、对象、字符串、属性)。 + Forge, } fn main() -> ExitCode { @@ -156,10 +199,88 @@ fn main() -> ExitCode { bench::run_benchmarks(config, kernel, pool) } Some(Commands::Test { .. }) => not_implemented("test"), + Some(Commands::Server { command }) => server_command(command), None => repl(), } } +/// server 子命令分派:start(两形态)与 cleanup 为最小实现,其余八臂为 +/// not_implemented 占位。 +/// +/// # 步骤 +/// 1. Start:rm 为假调持久 server 入口(well-known 路径),rm 为真调独立 +/// 模式入口(进程唯一路径);`--workers` 为 Some 时覆盖 worker 数。 +/// 2. Cleanup:调 well-known 路径清理入口,四态结果映射退出码(无残留与 +/// 已清理退 0,拒绝与杀进程失败退 1)并打印结果。 +/// 3. 其余八臂:not_implemented 占位(退出码 2)。 +/// +/// # 边界与前提 +/// - `--idle-timeout` 不带 --rm 时静默忽略(语义门控归后续任务)。 +/// - 全局旗标 -v / -q / --profile 在 server 臂为空操作。 +/// +/// # 副作用 +/// - start 阻塞当前进程至 server 退出(持久形态为前台常驻)。 +/// - cleanup 可能向 sidecar 记录的进程发 SIGTERM 并删除残留文件。 +fn server_command(command: ServerCommands) -> ExitCode { + match command { + ServerCommands::Start { rm, idle_timeout, workers } => { + if rm { + let mut config = RmServerConfig { + idle_timeout: Duration::from_secs(idle_timeout), + ..RmServerConfig::default() + }; + if let Some(n) = workers { + config.worker_count = n as usize; + } + start_result(run_server_rm(&config)) + } else { + let mut config = ServerConfig::well_known(); + if let Some(n) = workers { + config.worker_count = n as usize; + } + start_result(run_server(&config)) + } + } + ServerCommands::Cleanup => match well_known_cleanup() { + CleanupOutcome::Clean => { + println!("无残留:sidecar 与 socket 文件均不存在。"); + ExitCode::SUCCESS + } + CleanupOutcome::Removed => { + println!("残留已清理:孤儿文件已删除。"); + ExitCode::SUCCESS + } + CleanupOutcome::RefusedLiveServer => { + eprintln!("存在存活 server 且进程号无法可靠确定,未触碰文件,需人工检查。"); + ExitCode::FAILURE + } + CleanupOutcome::KillFailed => { + eprintln!("已发终止信号但进程在等待窗口内未退出,未触碰文件。"); + 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::Restart => not_implemented("server restart"), + ServerCommands::Log => not_implemented("server log"), + ServerCommands::Forge => not_implemented("server forge"), + } +} + +/// 启动结果到退出码:成功退 0,错误以红色打印到 stderr 后退 1。 +fn start_result(result: Result<(), oxide_cli::server::server::ServerError>) -> ExitCode { + match result { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("{}", Red.paint(format!("server 启动失败:{err}"))); + ExitCode::FAILURE + } + } +} + fn make_kernel(verbose: bool, quiet: bool) -> Arc { let mut config = KernelConfig::standard(); if verbose { diff --git a/crates/oxide_cli/tests/server_cli.rs b/crates/oxide_cli/tests/server_cli.rs new file mode 100644 index 00000000..85b78cd2 --- /dev/null +++ b/crates/oxide_cli/tests/server_cli.rs @@ -0,0 +1,134 @@ +//! server CLI 集成测试:真实二进制的分派面(help 列子命令、占位臂退出码、 +//! cleanup 幂等、start 端到端)。 +//! +//! start 端到端与 cleanup 走 well-known 全局路径(每用户单例):测试先探活, +//! socket 存活即 panic 不抢占存活 server;两枚触碰全局路径的测试经同一把 +//! 锁串行,避免相互干扰。 + +use std::io::{BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::process::{Command, Output}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use oxide_cli::server::protocol::{self, FrameReader, ServerRequest, ServerResponse}; +use oxide_cli::server::sidecar; + +/// well-known 全局路径互斥锁:同测试二进制内触碰全局路径的测试串行执行。 +static WELL_KNOWN_LOCK: Mutex<()> = Mutex::new(()); + +/// 运行 oxide 二进制并返回输出。 +fn oxide(args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_oxide")) + .args(args) + .output() + .expect("failed to run oxide") +} + +/// 发送请求帧并读回一帧响应。 +fn send_and_recv(stream: &mut UnixStream, request: &ServerRequest) -> ServerResponse { + stream + .write_all(protocol::encode_request(request).as_bytes()) + .expect("写请求应成功"); + let mut reader = FrameReader::new(BufReader::new(stream)); + let frame = reader.read_frame().expect("读响应应成功").expect("应读到响应帧"); + protocol::parse_response(&frame).expect("响应帧解析应成功") +} + +/// help 列全部十个子命令。 +#[test] +fn server_help_lists_subcommands() { + let output = oxide(&["server", "--help"]); + assert!(output.status.success(), "server --help 应退 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + for name in [ + "start", "stop", "status", "health", "info", "version", "cleanup", "restart", "log", "forge", + ] { + assert!(stdout.contains(name), "help 应列出 {name}:{stdout}"); + } +} + +/// 八个占位臂各退 2,stderr 含 not yet implemented。 +#[test] +fn server_stub_subcommands_exit_2() { + for sub in ["stop", "status", "health", "info", "version", "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); + assert!(stderr.contains("not yet implemented"), "{sub} 应打印未实现提示:{stderr}"); + } +} + +/// cleanup 退 0(幂等:有残留则清理、无残留则报无残留),事后全局路径无文件。 +#[test] +fn server_cleanup_exit_0() { + let _guard = WELL_KNOWN_LOCK.lock().expect("全局路径锁不应中毒"); + assert!( + !sidecar::is_server_alive(&sidecar::well_known_socket_path()), + "存活 server 占用 well-known 路径,测试不抢占" + ); + + let output = oxide(&["server", "cleanup"]); + assert_eq!(output.status.code(), Some(0), "cleanup 应退 0"); + assert!(!sidecar::well_known_socket_path().exists(), "socket 文件应不存在"); + assert!(!sidecar::well_known_sidecar_path().exists(), "sidecar 文件应不存在"); +} + +/// start 端到端:起真实子进程,well-known 路径连接,版本帧断言构建期版本, +/// 关闭帧后子进程退 0,socket 与 sidecar 文件被删。 +#[test] +fn server_start_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 路径,测试不抢占"); + + // worker 数压到 2:避免按宿主核数预热的成本。 + let mut child = Command::new(env!("CARGO_BIN_EXE_oxide")) + .args(["server", "start", "--workers", "2"]) + .spawn() + .expect("oxide server start 应可启动"); + + // 轮询 socket 文件出现(10 秒截止)判就绪。 + let deadline = Instant::now() + Duration::from_secs(10); + while !socket.exists() { + assert!(Instant::now() < deadline, "socket 文件未出现"); + std::thread::sleep(Duration::from_millis(10)); + } + + // 版本请求帧:版本应等于构建期版本。 + let mut stream = UnixStream::connect(&socket).expect("连接 server 应成功"); + let response = send_and_recv(&mut stream, &ServerRequest::Version); + match response { + ServerResponse::Version { version } => { + assert_eq!(version, env!("CARGO_PKG_VERSION"), "版本应与构建期版本一致"); + } + other => panic!("应得 Version 帧,实得 {other:?}"), + } + + // 关闭请求帧:读回关闭确认帧。 + let response = send_and_recv(&mut stream, &ServerRequest::Shutdown); + assert!(matches!(response, ServerResponse::Shutdown), "应得关闭确认帧:{response:?}"); + drop(stream); + + // 等子进程以退出码 0 退出(30 秒截止)。 + 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}"), + } + } + + assert!(!socket.exists(), "socket 文件应被删除"); + assert!(!sidecar_path.exists(), "sidecar 文件应被删除"); +}