diff --git a/Cargo.lock b/Cargo.lock index ab32512f..f215250a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -136,6 +136,15 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -179,6 +188,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chrono" version = "0.4.45" @@ -374,6 +389,17 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix 0.31.3", + "windows-sys 0.61.2", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -394,6 +420,18 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + [[package]] name = "dragonbox_ecma" version = "0.1.12" @@ -778,7 +816,19 @@ checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ "bitflags", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.1.1", + "libc", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases 0.2.2", "libc", ] @@ -831,6 +881,21 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + [[package]] name = "once_cell" version = "1.21.4" @@ -1095,7 +1160,7 @@ dependencies = [ [[package]] name = "oxide_builtins" -version = "0.8.30" +version = "0.8.31" dependencies = [ "chrono", "fast-float", @@ -1120,7 +1185,7 @@ dependencies = [ [[package]] name = "oxide_bytecode" -version = "0.8.30" +version = "0.8.31" dependencies = [ "num-bigint", "oxide_log", @@ -1128,7 +1193,7 @@ dependencies = [ [[package]] name = "oxide_cfg" -version = "0.8.30" +version = "0.8.31" dependencies = [ "oxide_bytecode", "oxide_emit", @@ -1139,11 +1204,12 @@ dependencies = [ [[package]] name = "oxide_cli" -version = "0.8.30" +version = "0.8.31" dependencies = [ "ansi_term", "bumpalo", "clap", + "ctrlc", "oxide_bytecode", "oxide_code_cache", "oxide_compiler", @@ -1159,7 +1225,7 @@ dependencies = [ [[package]] name = "oxide_code_cache" -version = "0.8.30" +version = "0.8.31" dependencies = [ "lru", "oxide_bytecode", @@ -1168,7 +1234,7 @@ dependencies = [ [[package]] name = "oxide_compiler" -version = "0.8.30" +version = "0.8.31" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1187,7 +1253,7 @@ dependencies = [ [[package]] name = "oxide_dce" -version = "0.8.30" +version = "0.8.31" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1201,7 +1267,7 @@ dependencies = [ [[package]] name = "oxide_emit" -version = "0.8.30" +version = "0.8.31" dependencies = [ "num-bigint", "oxc_allocator", @@ -1216,7 +1282,7 @@ dependencies = [ [[package]] name = "oxide_ir" -version = "0.8.30" +version = "0.8.31" dependencies = [ "oxide_bytecode", "oxide_log", @@ -1225,7 +1291,7 @@ dependencies = [ [[package]] name = "oxide_kernel" -version = "0.8.30" +version = "0.8.31" dependencies = [ "bumpalo", "dashmap", @@ -1240,7 +1306,7 @@ dependencies = [ [[package]] name = "oxide_liveness" -version = "0.8.30" +version = "0.8.31" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1252,7 +1318,7 @@ dependencies = [ [[package]] name = "oxide_log" -version = "0.8.30" +version = "0.8.31" dependencies = [ "tracing", "tracing-appender", @@ -1261,7 +1327,7 @@ dependencies = [ [[package]] name = "oxide_parser" -version = "0.8.30" +version = "0.8.31" dependencies = [ "oxc_allocator", "oxc_ast", @@ -1276,7 +1342,7 @@ dependencies = [ [[package]] name = "oxide_regalloc" -version = "0.8.30" +version = "0.8.31" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1292,7 +1358,7 @@ dependencies = [ [[package]] name = "oxide_runtime_api" -version = "0.8.30" +version = "0.8.31" dependencies = [ "num-bigint", "num-traits", @@ -1304,7 +1370,7 @@ dependencies = [ [[package]] name = "oxide_test262" -version = "0.8.30" +version = "0.8.31" dependencies = [ "oxide_bytecode", "oxide_compiler", @@ -1321,7 +1387,7 @@ dependencies = [ [[package]] name = "oxide_types" -version = "0.8.30" +version = "0.8.31" dependencies = [ "bumpalo", "hashbrown 0.14.5", @@ -1333,7 +1399,7 @@ dependencies = [ [[package]] name = "oxide_vm" -version = "0.8.30" +version = "0.8.31" dependencies = [ "bumpalo", "chrono", @@ -1704,7 +1770,7 @@ dependencies = [ "libc", "log", "memchr", - "nix", + "nix 0.28.0", "radix_trie", "unicode-segmentation", "unicode-width 0.1.14", diff --git a/Cargo.toml b/Cargo.toml index 2fdb9bf8..96644afb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ members = [ ] [workspace.package] -version = "0.8.30" +version = "0.8.31" edition = "2021" license = "MIT" rust-version = "1.80" @@ -38,6 +38,7 @@ oxc_syntax = "0.134.0" oxide_ir = { path = "crates/oxide_ir" } oxide_emit = { path = "crates/oxide_emit" } clap = { version = "4", features = ["derive"] } +ctrlc = { version = "3.4", features = ["termination"] } rustyline = "14" ansi_term = "0.12" serde = "1" diff --git a/crates/oxide_cli/Cargo.toml b/crates/oxide_cli/Cargo.toml index e3011c39..584aa1d3 100644 --- a/crates/oxide_cli/Cargo.toml +++ b/crates/oxide_cli/Cargo.toml @@ -21,6 +21,7 @@ oxide_kernel = { path = "../oxide_kernel" } oxide_log = { path = "../oxide_log" } bumpalo.workspace = true clap.workspace = true +ctrlc.workspace = true rustyline.workspace = true ansi_term.workspace = true serde_json.workspace = true diff --git a/crates/oxide_cli/src/lib.rs b/crates/oxide_cli/src/lib.rs new file mode 100644 index 00000000..c6fe389b --- /dev/null +++ b/crates/oxide_cli/src/lib.rs @@ -0,0 +1,104 @@ +//! oxide_cli 库目标:暴露持久 server 子系统与完成值渲染,供集成测试跨进程 +//! 引用、供二进制与 server 执行路径共用。 +//! +//! 二进制目标(`src/main.rs`)承载 CLI 分派(eval / run / compile / bench / +//! REPL);库目标暴露 server 模块与完成值渲染函数,集成测试(`tests/`)经 +//! `oxide_cli::server` 调用 `run_server` 起真实 server 进程主体,eval 臂与 +//! server 执行路径共用 `format_js_value` 渲染完成值。 + +use oxide_kernel::shape_forge::{ShapeForge, EMPTY_SHAPE_ID}; +use oxide_kernel::string_forge::PermInterner; +use oxide_types::object::JsObject; +use oxide_types::private_key::{is_private_name_key, is_symbol_key}; +use oxide_vm::vm::Vm; +use oxide_vm::JsValue; + +pub mod server; + +/// 完成值渲染:把 JsValue 递归渲染为可读文本(字符串加引号、Promise 按结算态 +/// 展开、函数 `[Function]`、对象走 shape 链、数组按元素区)。 +/// +/// 二进制 eval 臂与 server 执行路径共用。 +pub fn format_js_value(vm: &Vm, string_forge: &PermInterner, shape_forge: &ShapeForge, val: JsValue) -> String { + if val.is_string() { + // SAFETY: val 已确认是字符串值。 + let s = unsafe { (*val.as_string_ptr()).to_owned_string() }; + format!("\"{s}\"") + } else if val.is_bigint() { + format!("{}", vm.bigint_value(val)) + } else if val.is_object() { + let obj = unsafe { &*val.as_js_object_ptr() }; + if obj.is_promise_obj() { + // 已 settle 的 Promise 打印其结算值(便于 eval 观察微任务结果)。 + return match oxide_vm::promise::promise_settled_value(obj) { + Some((true, v)) => format_js_value(vm, string_forge, shape_forge, v), + Some((false, v)) => { + format!("Promise {{ {} }}", format_js_value(vm, string_forge, shape_forge, v)) + } + None => "Promise { }".to_string(), + }; + } + if obj.is_function() { + "[Function]".to_string() + } else if obj.is_array() { + format_array(vm, string_forge, shape_forge, obj) + } else { + format_object(vm, string_forge, shape_forge, obj) + } + } else if val.is_undefined() { + "undefined".to_string() + } else { + format!("{val}") + } +} + +/// 对象渲染:沿 shape 父链收集非空属性名(跳过 Symbol/私有名键),按序渲染。 +fn format_object(vm: &Vm, string_forge: &PermInterner, shape_forge: &ShapeForge, obj: &JsObject) -> String { + let mut entries = Vec::new(); + let shape_id = obj.shape_id(); + let mut shape_ids = Vec::new(); + let mut cursor = Some(shape_id); + while let Some(id) = cursor { + if id == EMPTY_SHAPE_ID { + break; + } + if let Some(shape) = shape_forge.get_shape(id) { + cursor = shape.parent; + if shape.property_name != u32::MAX { + shape_ids.push(id); + } + } else { + break; + } + } + let mut pos: u32 = 0; + for id in shape_ids.iter().rev() { + if let Some(shape) = shape_forge.get_shape(*id) { + // 跳过 Symbol/私有名键,仅展示字符串属性名。 + if !is_symbol_key(shape.property_name) && !is_private_name_key(shape.property_name) { + let prop_val = obj.get_prop_at(pos); + if prop_val.is_undefined() { + pos += 1; + continue; + } + let name = string_forge.lookup(shape.property_name).unwrap_or_default(); + let val_str = format_js_value(vm, string_forge, shape_forge, prop_val); + entries.push(format!("\"{name}\": {val_str}")); + } + } + pos += 1; + } + format!("{{{}}}", entries.join(", ")) +} + +/// 数组渲染:按元素区长度遍历,逐元素渲染。 +fn format_array(vm: &Vm, string_forge: &PermInterner, shape_forge: &ShapeForge, obj: &JsObject) -> String { + // 数组元素在独立元素区,命名属性区长度恒 0;元素区未分配时 get_prop_at 自然返回 undefined。 + let len = obj.array_prop_count as usize; + let mut items = Vec::new(); + for i in 0..len { + let val = obj.get_prop_at(i); + items.push(format_js_value(vm, string_forge, shape_forge, val)); + } + format!("[{}]", items.join(", ")) +} diff --git a/crates/oxide_cli/src/main.rs b/crates/oxide_cli/src/main.rs index 39821c8f..96ef3757 100644 --- a/crates/oxide_cli/src/main.rs +++ b/crates/oxide_cli/src/main.rs @@ -10,21 +10,19 @@ use ansi_term::Colour::Red; use clap::{Parser, Subcommand}; 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, EMPTY_SHAPE_ID}; +use oxide_kernel::shape_forge::ShapeForge; use oxide_kernel::string_forge::PermInterner; use oxide_kernel::{kernel_error, kernel_info}; use oxide_log::{Level, SUBSYSTEM_COUNT}; use oxide_parser::Allocator; -use oxide_types::object::JsObject; -use oxide_types::private_key::{is_private_name_key, is_symbol_key}; use oxide_vm::vm::Vm; use oxide_vm::vm_error; use oxide_vm::vm_pool::VmPool; use oxide_vm::JsValue; mod bench; -mod server; #[derive(Parser)] #[command( @@ -234,94 +232,6 @@ fn format_result(vm: &oxide_vm::vm::Vm, string_forge: &PermInterner, shape_forge println!("{}", format_js_value(vm, string_forge, shape_forge, val)); } -fn format_js_value( - vm: &oxide_vm::vm::Vm, string_forge: &PermInterner, shape_forge: &ShapeForge, val: JsValue, -) -> String { - if val.is_string() { - // SAFETY: val 已确认是字符串值。 - let s = unsafe { (*val.as_string_ptr()).to_owned_string() }; - format!("\"{s}\"") - } else if val.is_bigint() { - format!("{}", vm.bigint_value(val)) - } else if val.is_object() { - let obj = unsafe { &*val.as_js_object_ptr() }; - if obj.is_promise_obj() { - // 已 settle 的 Promise 打印其结算值(便于 eval 观察微任务结果)。 - return match oxide_vm::promise::promise_settled_value(obj) { - Some((true, v)) => format_js_value(vm, string_forge, shape_forge, v), - Some((false, v)) => { - format!("Promise {{ {} }}", format_js_value(vm, string_forge, shape_forge, v)) - } - None => "Promise { }".to_string(), - }; - } - if obj.is_function() { - "[Function]".to_string() - } else if obj.is_array() { - format_array(vm, string_forge, shape_forge, obj) - } else { - format_object(vm, string_forge, shape_forge, obj) - } - } else if val.is_undefined() { - "undefined".to_string() - } else { - format!("{val}") - } -} - -fn format_object( - vm: &oxide_vm::vm::Vm, string_forge: &PermInterner, shape_forge: &ShapeForge, obj: &JsObject, -) -> String { - let mut entries = Vec::new(); - let shape_id = obj.shape_id(); - let mut shape_ids = Vec::new(); - let mut cursor = Some(shape_id); - while let Some(id) = cursor { - if id == EMPTY_SHAPE_ID { - break; - } - if let Some(shape) = shape_forge.get_shape(id) { - cursor = shape.parent; - if shape.property_name != u32::MAX { - shape_ids.push(id); - } - } else { - break; - } - } - let mut pos: u32 = 0; - for id in shape_ids.iter().rev() { - if let Some(shape) = shape_forge.get_shape(*id) { - // 跳过 Symbol/私有名键,仅展示字符串属性名。 - if !is_symbol_key(shape.property_name) && !is_private_name_key(shape.property_name) { - let prop_val = obj.get_prop_at(pos); - if prop_val.is_undefined() { - pos += 1; - continue; - } - let name = string_forge.lookup(shape.property_name).unwrap_or_default(); - let val_str = format_js_value(vm, string_forge, shape_forge, prop_val); - entries.push(format!("\"{name}\": {val_str}")); - } - } - pos += 1; - } - format!("{{{}}}", entries.join(", ")) -} - -fn format_array( - vm: &oxide_vm::vm::Vm, string_forge: &PermInterner, shape_forge: &ShapeForge, obj: &JsObject, -) -> String { - // 数组元素在独立元素区,命名属性区长度恒 0;元素区未分配时 get_prop_at 自然返回 undefined。 - let len = obj.array_prop_count as usize; - let mut items = Vec::new(); - for i in 0..len { - let val = obj.get_prop_at(i); - items.push(format_js_value(vm, string_forge, shape_forge, val)); - } - format!("[{}]", items.join(", ")) -} - fn run(file: &str, kernel: &Arc, pool: &Arc, trace: bool, profile: bool) -> ExitCode { match fs::read_to_string(file) { Ok(source) => eval(&source, kernel, pool, trace, profile, false), diff --git a/crates/oxide_cli/src/server/mod.rs b/crates/oxide_cli/src/server/mod.rs index 0d3dd4b4..d8fb46b8 100644 --- a/crates/oxide_cli/src/server/mod.rs +++ b/crates/oxide_cli/src/server/mod.rs @@ -12,7 +12,8 @@ pub mod eval; /// 协议数据面:NDJSON 帧格式与 serde 结构体。 pub mod protocol; -/// server 进程主体:accept 循环、控制请求分派、执行请求路由与优雅退出。 +/// server 进程主体:accept 循环、控制请求分派、执行请求路由、信号处理 +/// (SIGINT/SIGTERM 置位关闭标志)与优雅退出。 #[allow(clippy::module_inception)] pub mod server; /// 身份注册:sidecar 文件、liveness 检查与陈旧清理。 diff --git a/crates/oxide_cli/src/server/server.rs b/crates/oxide_cli/src/server/server.rs index cd27dcc8..72555288 100644 --- a/crates/oxide_cli/src/server/server.rs +++ b/crates/oxide_cli/src/server/server.rs @@ -8,8 +8,8 @@ //! - 并发模型:主线程只跑 accept 循环;每连接一个标准库线程只做 I/O //! (帧读写);五类控制请求由连接线程直接应答、不占 worker;执行请求 //! 经 mpsc 路由到固定 N 个常驻 worker 执行(各持自己线程上的 VM 池)。 -//! - 优雅退出顺序:关闭请求 → 在途归零 → 清空发送端 → join worker → -//! drop 内核 → 删 sidecar 与 socket 文件。 +//! - 优雅退出顺序:关闭请求或 SIGINT/SIGTERM 信号置位关闭标志 → 在途归零 +//! → 清空发送端 → join worker → drop 内核 → 删 sidecar 与 socket 文件。 //! - 池本体不是 Send/Sync(Vm 不跨线程),上下文只持池的原子状态句柄; //! 状态响应读全体 worker 池的共享聚合计数器。 @@ -29,7 +29,8 @@ use super::protocol::{self, FrameReader, ProtocolError, ServerRequest, ServerRes use super::sidecar::{self, ClaimResult}; use super::workers::{self, WorkerRouter}; -/// server 启动与运行错误:绑定失败、认领被拒、接受连接失败、读取身份失败。 +/// server 启动与运行错误:绑定失败、认领被拒、接受连接失败、读取身份失败、 +/// 信号处理器注册失败。 #[derive(Debug, Clone, PartialEq, Eq)] pub enum ServerError { /// sidecar 认领未通过(已有存活 server 或存活证据不足以裁决)。 @@ -42,6 +43,8 @@ pub enum ServerError { IdentityReadFailed(String), /// worker 线程异常退出(panic 载荷文本)。 WorkerPanic(String), + /// 信号处理器注册失败(系统级错误,非"已注册"的幂等情形)。 + SignalInstallFailed(String), } impl std::fmt::Display for ServerError { @@ -52,6 +55,7 @@ impl std::fmt::Display for ServerError { ServerError::AcceptFailed(msg) => write!(f, "接受连接失败:{msg}"), ServerError::IdentityReadFailed(msg) => write!(f, "读取身份失败:{msg}"), ServerError::WorkerPanic(msg) => write!(f, "worker 线程异常退出:{msg}"), + ServerError::SignalInstallFailed(msg) => write!(f, "信号处理器注册失败:{msg}"), } } } @@ -93,6 +97,9 @@ fn default_worker_count() -> usize { /// 内核跨线程共享(`Arc` 是 Send/Sync);池本体不进上下文 /// (Vm 不跨线程,池归各 worker 线程所有),状态响应读共享聚合计数器。 struct ServerContext { + // 只持不读:余留连接线程(在途排水超时后仍存活)持本上下文,内核句柄 + // 随上下文存活,不悬垂。 + #[allow(dead_code)] kernel: Arc, started_at: Instant, shutdown: Arc, @@ -101,28 +108,31 @@ struct ServerContext { socket_path: String, } -/// server 进程主体:认领 sidecar → 绑定 socket → 建内核 → 派生 worker → -/// accept 循环 → 在途归零 → 清空发送端 → join worker → drop 内核 → -/// 删 sidecar 与 socket 文件。 +/// server 进程主体:认领 sidecar → 注册信号处理器 → 绑定 socket → 建内核 → +/// 派生 worker → accept 循环 → 在途归零 → 清空发送端 → join worker → +/// drop 内核 → 删 sidecar 与 socket 文件。 /// /// # 步骤 /// 1. 认领 sidecar(启动顺序契约:先写 sidecar 再 bind socket)。 -/// 2. 绑定监听器;失败且路径存在时删 stale socket 文件重试一次。 -/// 3. 建内核(标准配置)与共享聚合计数器。 -/// 4. 派生 N 个常驻 worker(各在自己线程上建自有池)。 -/// 5. 进入 accept 循环;关闭标志置位后按关闭序列退出。 +/// 2. 注册信号处理器(绑定 socket 之前;失败回滚 sidecar,此时无存活线程)。 +/// 3. 绑定监听器;失败且路径存在时删 stale socket 文件重试一次。 +/// 4. 建内核(标准配置)与共享聚合计数器。 +/// 5. 派生 N 个常驻 worker(各在自己线程上建自有池)。 +/// 6. 进入 accept 循环;关闭标志置位后按关闭序列退出。 /// /// # 边界与前提 /// - 认领被拒(AlreadyRunning / RefusedAmbiguous)立即返回错误,不双注册。 +/// - 信号处理器注册失败(系统级错误)回滚 sidecar 并以错误返回。 /// - stale socket 文件删除重试后再失败时回滚删除 sidecar 并以错误返回。 /// /// # 副作用 /// - 创建 sidecar 与 socket 文件;正常退出时删除两者。 +/// - 注册进程级信号处理器(SIGINT、SIGTERM,termination 特性下含 SIGHUP)。 /// - 派生 N 个常驻 worker 线程与每连接一个 I/O 线程。 /// /// # 注意事项 /// - 在调用线程上运行(后续 CLI 骨架任务在主线程分派到它)。 -/// - 关闭标志由关闭请求置位;信号处理由后续任务在同一标志上接线。 +/// - 关闭标志由关闭请求或 SIGINT/SIGTERM 信号置位。 /// - 关闭序列固定:清空发送端 → join 全部 worker → drop 内核(内核 drop /// 的调试断言要求存活 VM 数为零,worker 池随线程退出先释放,前提满足)。 pub fn run_server(config: &ServerConfig) -> Result<(), ServerError> { @@ -132,6 +142,15 @@ pub fn run_server(config: &ServerConfig) -> Result<(), ServerError> { other => return Err(ServerError::ClaimRefused(other)), } + // 关闭标志提前创建并注册信号处理器:在绑定 socket 之前,socket 文件 + // 出现即蕴含处理器已就位(集成测试的信号投递无竞态);失败回滚 + // sidecar,此时进程内没有存活线程。 + let shutdown = Arc::new(AtomicBool::new(false)); + if let Err(err) = install_signal_handler(&shutdown) { + let _ = fs::remove_file(&config.sidecar_path); + return Err(err); + } + // 绑定监听器:stale socket 文件删一次重试,失败回滚 sidecar。 let listener = bind_listener(config)?; @@ -152,7 +171,7 @@ pub fn run_server(config: &ServerConfig) -> Result<(), ServerError> { let ctx = Arc::new(ServerContext { kernel: Arc::clone(&kernel), started_at: Instant::now(), - shutdown: Arc::new(AtomicBool::new(false)), + shutdown: Arc::clone(&shutdown), in_flight: Arc::new(AtomicUsize::new(0)), pool_counters: counters, socket_path: config.socket_path.to_string_lossy().into_owned(), @@ -184,6 +203,44 @@ pub fn run_server(config: &ServerConfig) -> Result<(), ServerError> { accept_result } +/// 信号处理器体:置位关闭标志。 +/// +/// # 边界与前提 +/// - 只做一次原子写,不分配、不取锁、不做 I/O;即使按最严格的信号上下文 +/// 标准衡量也是安全的。 +fn signal_shutdown(shutdown: &Arc) { + shutdown.store(true, Ordering::SeqCst); +} + +/// 注册 SIGINT/SIGTERM 信号处理器:信号到达时置位关闭标志。 +/// +/// # 步骤 +/// 1. 闭包捕获关闭标志的共享句柄,转调 `signal_shutdown`。 +/// 2. `ctrlc::set_handler` 注册;"已注册"的幂等情形视为成功,其余系统 +/// 错误映射为 `SignalInstallFailed`。 +/// +/// # 边界与前提 +/// - 处理器是进程级全局的(同一进程只允许一次注册);生产路径一个进程 +/// 只有一个 server,注册一次即成功。 +/// +/// # 副作用 +/// - 注册进程级信号处理器(SIGINT、SIGTERM,termination 特性下含 SIGHUP)。 +/// +/// # 注意事项 +/// - 须在绑定 socket 之前调用:socket 文件出现即蕴含处理器已就位, +/// 集成测试的信号投递无竞态。 +/// - 测试进程内多 server 并存时,仅首个注册者接上信号,其余返回"已注册" +/// 幂等成功;单测不发真实信号,无干扰。 +fn install_signal_handler(shutdown: &Arc) -> Result<(), ServerError> { + let flag = Arc::clone(shutdown); + match ctrlc::set_handler(move || signal_shutdown(&flag)) { + Ok(()) => Ok(()), + // 进程级处理器已注册(测试进程内多 server 并存的幂等情形)。 + Err(ctrlc::Error::MultipleHandlers) => Ok(()), + Err(e) => Err(ServerError::SignalInstallFailed(e.to_string())), + } +} + /// 从 `catch_unwind` 的 panic payload 提取可读文本:`&str` 与 `String` 两种 /// 常见形态,其余返回占位文本。 fn panic_payload_str(payload: &Box) -> String { @@ -430,6 +487,21 @@ mod tests { assert!(result.is_ok(), "server 应正常退出:{result:?}"); } + /// 信号处理器体:置位关闭标志。 + #[test] + fn signal_shutdown_sets_flag() { + let shutdown = Arc::new(AtomicBool::new(false)); + signal_shutdown(&shutdown); + assert!(shutdown.load(Ordering::SeqCst), "标志应被置位"); + } + + /// 注册信号处理器:返回成功("已注册"的幂等情形同样视为成功)。 + #[test] + fn install_signal_handler_registers() { + let shutdown = Arc::new(AtomicBool::new(false)); + install_signal_handler(&shutdown).expect("信号处理器注册应成功"); + } + /// 版本请求得 Version 帧,取值与构建期版本一致。 #[test] fn version_response() { diff --git a/crates/oxide_cli/tests/server_signal_int.rs b/crates/oxide_cli/tests/server_signal_int.rs new file mode 100644 index 00000000..729a53ab --- /dev/null +++ b/crates/oxide_cli/tests/server_signal_int.rs @@ -0,0 +1,71 @@ +//! 信号处理集成测试(SIGINT):真实信号路径触发优雅退出。 +//! +//! 独立进程、进程内唯一 server 与唯一信号处理器:`ctrlc` 的处理器注册是 +//! 进程级一次性的,信号投递无歧义,测试确定。SIGTERM 路径见 +//! `server_signal_term.rs`(同型,独立进程)。 + +use std::process::Command; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use oxide_cli::server::server::{run_server, ServerConfig, ServerError}; + +/// 唯一临时路径(进程号加测试名):单进程内唯一,进程间以进程号隔离。 +/// worker 数取小值 2:与既有单测同口径,大值徒增预热成本。 +fn unique_paths(test_name: &str) -> ServerConfig { + let dir = std::env::temp_dir().join(format!( + "oxide_server_signal_int_{}_{}", + std::process::id(), + test_name + )); + std::fs::create_dir_all(&dir).expect("测试目录创建应成功"); + ServerConfig { + socket_path: dir.join("server.sock"), + sidecar_path: dir.join("sidecar.json"), + worker_count: 2, + } +} + +/// 后台线程起真实 run_server,等 socket 文件出现(bind 成功即就绪, +/// 且蕴含信号处理器已注册——注册在绑定之前)。 +fn start_server(config: ServerConfig) -> 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 +} + +/// 给本测试进程发 SIGINT(`kill` 二进制与 sidecar 的 `pid_alive` 同一口径)。 +fn send_sigint() { + let status = Command::new("kill") + .args(["-INT", &std::process::id().to_string()]) + .status() + .expect("kill 应可执行"); + assert!(status.success(), "kill -INT 应成功:{status:?}"); +} + +/// 等 server 线程退出,断言正常退出与 sidecar、socket 文件均被删除。 +fn wait_shutdown(config: &ServerConfig, handle: JoinHandle>) { + let deadline = Instant::now() + Duration::from_secs(30); + while !handle.is_finished() { + assert!(Instant::now() < deadline, "server 线程应在有界时间内退出"); + std::thread::sleep(Duration::from_millis(10)); + } + let result = handle.join().expect("server 线程应正常退出"); + assert!(result.is_ok(), "server 应正常退出:{result:?}"); + assert!(!config.sidecar_path.exists(), "sidecar 文件应被删除"); + assert!(!config.socket_path.exists(), "socket 文件应被删除"); +} + +/// SIGINT 触发优雅退出:在途归零、删 sidecar 与 socket 文件。 +#[test] +fn sigint_graceful_shutdown() { + let config = unique_paths("sigint"); + let handle = start_server(config.clone()); + send_sigint(); + wait_shutdown(&config, handle); +} diff --git a/crates/oxide_cli/tests/server_signal_term.rs b/crates/oxide_cli/tests/server_signal_term.rs new file mode 100644 index 00000000..fd18d0a4 --- /dev/null +++ b/crates/oxide_cli/tests/server_signal_term.rs @@ -0,0 +1,71 @@ +//! 信号处理集成测试(SIGTERM):真实信号路径触发优雅退出。 +//! +//! 独立进程、进程内唯一 server 与唯一信号处理器:`ctrlc` 的处理器注册是 +//! 进程级一次性的,信号投递无歧义,测试确定。SIGINT 路径见 +//! `server_signal_int.rs`(同型,独立进程)。 + +use std::process::Command; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use oxide_cli::server::server::{run_server, ServerConfig, ServerError}; + +/// 唯一临时路径(进程号加测试名):单进程内唯一,进程间以进程号隔离。 +/// worker 数取小值 2:与既有单测同口径,大值徒增预热成本。 +fn unique_paths(test_name: &str) -> ServerConfig { + let dir = std::env::temp_dir().join(format!( + "oxide_server_signal_term_{}_{}", + std::process::id(), + test_name + )); + std::fs::create_dir_all(&dir).expect("测试目录创建应成功"); + ServerConfig { + socket_path: dir.join("server.sock"), + sidecar_path: dir.join("sidecar.json"), + worker_count: 2, + } +} + +/// 后台线程起真实 run_server,等 socket 文件出现(bind 成功即就绪, +/// 且蕴含信号处理器已注册——注册在绑定之前)。 +fn start_server(config: ServerConfig) -> 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 +} + +/// 给本测试进程发 SIGTERM(`kill` 二进制与 sidecar 的 `pid_alive` 同一口径)。 +fn send_sigterm() { + let status = Command::new("kill") + .args(["-TERM", &std::process::id().to_string()]) + .status() + .expect("kill 应可执行"); + assert!(status.success(), "kill -TERM 应成功:{status:?}"); +} + +/// 等 server 线程退出,断言正常退出与 sidecar、socket 文件均被删除。 +fn wait_shutdown(config: &ServerConfig, handle: JoinHandle>) { + let deadline = Instant::now() + Duration::from_secs(30); + while !handle.is_finished() { + assert!(Instant::now() < deadline, "server 线程应在有界时间内退出"); + std::thread::sleep(Duration::from_millis(10)); + } + let result = handle.join().expect("server 线程应正常退出"); + assert!(result.is_ok(), "server 应正常退出:{result:?}"); + assert!(!config.sidecar_path.exists(), "sidecar 文件应被删除"); + assert!(!config.socket_path.exists(), "socket 文件应被删除"); +} + +/// SIGTERM 触发优雅退出:在途归零、删 sidecar 与 socket 文件。 +#[test] +fn sigterm_graceful_shutdown() { + let config = unique_paths("sigterm"); + let handle = start_server(config.clone()); + send_sigterm(); + wait_shutdown(&config, handle); +}