diff --git a/Cargo.lock b/Cargo.lock index eaafea20..d99df332 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1160,7 +1160,7 @@ dependencies = [ [[package]] name = "oxide_builtins" -version = "0.8.39" +version = "0.8.40" dependencies = [ "chrono", "fast-float", @@ -1185,7 +1185,7 @@ dependencies = [ [[package]] name = "oxide_bytecode" -version = "0.8.39" +version = "0.8.40" dependencies = [ "num-bigint", "oxide_log", @@ -1193,7 +1193,7 @@ dependencies = [ [[package]] name = "oxide_cfg" -version = "0.8.39" +version = "0.8.40" dependencies = [ "oxide_bytecode", "oxide_emit", @@ -1204,7 +1204,7 @@ dependencies = [ [[package]] name = "oxide_cli" -version = "0.8.39" +version = "0.8.40" dependencies = [ "ansi_term", "bumpalo", @@ -1225,7 +1225,7 @@ dependencies = [ [[package]] name = "oxide_code_cache" -version = "0.8.39" +version = "0.8.40" dependencies = [ "lru", "oxide_bytecode", @@ -1234,7 +1234,7 @@ dependencies = [ [[package]] name = "oxide_compiler" -version = "0.8.39" +version = "0.8.40" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1253,7 +1253,7 @@ dependencies = [ [[package]] name = "oxide_dce" -version = "0.8.39" +version = "0.8.40" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1267,7 +1267,7 @@ dependencies = [ [[package]] name = "oxide_emit" -version = "0.8.39" +version = "0.8.40" dependencies = [ "num-bigint", "oxc_allocator", @@ -1282,7 +1282,7 @@ dependencies = [ [[package]] name = "oxide_ir" -version = "0.8.39" +version = "0.8.40" dependencies = [ "oxide_bytecode", "oxide_log", @@ -1291,7 +1291,7 @@ dependencies = [ [[package]] name = "oxide_kernel" -version = "0.8.39" +version = "0.8.40" dependencies = [ "bumpalo", "dashmap", @@ -1306,7 +1306,7 @@ dependencies = [ [[package]] name = "oxide_liveness" -version = "0.8.39" +version = "0.8.40" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1318,7 +1318,7 @@ dependencies = [ [[package]] name = "oxide_log" -version = "0.8.39" +version = "0.8.40" dependencies = [ "tracing", "tracing-appender", @@ -1327,7 +1327,7 @@ dependencies = [ [[package]] name = "oxide_parser" -version = "0.8.39" +version = "0.8.40" dependencies = [ "oxc_allocator", "oxc_ast", @@ -1342,7 +1342,7 @@ dependencies = [ [[package]] name = "oxide_regalloc" -version = "0.8.39" +version = "0.8.40" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1358,7 +1358,7 @@ dependencies = [ [[package]] name = "oxide_runtime_api" -version = "0.8.39" +version = "0.8.40" dependencies = [ "num-bigint", "num-traits", @@ -1370,7 +1370,7 @@ dependencies = [ [[package]] name = "oxide_test262" -version = "0.8.39" +version = "0.8.40" dependencies = [ "oxide_bytecode", "oxide_compiler", @@ -1387,7 +1387,7 @@ dependencies = [ [[package]] name = "oxide_types" -version = "0.8.39" +version = "0.8.40" dependencies = [ "bumpalo", "hashbrown 0.14.5", @@ -1399,7 +1399,7 @@ dependencies = [ [[package]] name = "oxide_vm" -version = "0.8.39" +version = "0.8.40" dependencies = [ "bumpalo", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 23c54b25..5f2ea838 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ members = [ ] [workspace.package] -version = "0.8.39" +version = "0.8.40" 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 154d0bef..fb175aa0 100644 --- a/crates/oxide_cli/src/main.rs +++ b/crates/oxide_cli/src/main.rs @@ -11,6 +11,7 @@ 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::log as server_log; use oxide_cli::server::protocol::{ServerRequest, ServerResponse}; use oxide_cli::server::server::{run_server, run_server_rm, RmServerConfig, ServerConfig}; use oxide_cli::server::sidecar; @@ -141,12 +142,43 @@ enum ServerCommands { Cleanup, /// 向存活 server 发关闭请求,等其退出后拉起新 server 进程。 Restart, - /// 查看 server 日志。 - Log, + /// 查看 server 日志(well-known 日志文件)。 + Log { + /// 持续跟踪新行(tail 语义,500 毫秒轮询)。 + #[arg(long)] + follow: bool, + /// 级别阈值过滤:只显示不低于该级别的行。 + #[arg(long, value_enum)] + level: Option, + /// 显示最后 N 行(缺省全部)。 + #[arg(long)] + lines: Option, + }, /// 查询内部状态(代码、对象、字符串、属性)。 Forge, } +/// 日志级别阈值(读取侧过滤,映射到 `oxide_log::Level`)。 +#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)] +enum LogLevel { + Error, + Warn, + Info, + Debug, +} + +impl LogLevel { + /// 映射到 `oxide_log::Level`。 + fn as_level(self) -> Level { + match self { + LogLevel::Error => Level::Error, + LogLevel::Warn => Level::Warn, + LogLevel::Info => Level::Info, + LogLevel::Debug => Level::Debug, + } + } +} + fn main() -> ExitCode { let cli = Cli::parse(); @@ -213,7 +245,8 @@ fn main() -> ExitCode { /// server 子命令分派:start(两形态)与 cleanup 为最小实现, /// version/status/health/info/stop/restart 六臂为真实实现, -/// 其余两臂为 not_implemented 占位。 +/// log 为真实实现(读 well-known 日志文件,支持 --follow/--level/--lines), +/// 其余一臂(forge)为 not_implemented 占位。 /// /// # 步骤 /// 1. Start:rm 为真调独立模式入口(进程唯一路径);foreground 为真当前进程 @@ -226,7 +259,9 @@ fn main() -> ExitCode { /// 成功打印渲染文本退 0,失败打印错误消息退 1。 /// 4. Restart:发关闭请求等旧 server 退出,拉起新 server 进程并探测就绪 /// (详见 restart_command)。 -/// 5. 其余两臂:not_implemented 占位(退出码 2)。 +/// 5. Log:读 well-known 日志文件,按级别阈值与最后 N 行过滤后打印, +/// `--follow` 时进入阻塞跟踪循环(详见 log_command)。 +/// 6. 其余一臂(forge):not_implemented 占位(退出码 2)。 /// /// # 边界与前提 /// - `--idle-timeout` 不带 --rm 时静默忽略(语义门控归后续任务)。 @@ -305,7 +340,7 @@ fn server_command(command: ServerCommands) -> ExitCode { _ => None, }), ServerCommands::Restart => restart_command(), - ServerCommands::Log => not_implemented("server log"), + ServerCommands::Log { follow, level, lines } => log_command(follow, level.map(LogLevel::as_level), lines), ServerCommands::Forge => not_implemented("server forge"), } } @@ -473,6 +508,42 @@ fn restart_command() -> ExitCode { ExitCode::SUCCESS } +/// log 子命令:读 well-known 日志文件,按级别阈值与最后 N 行过滤后打印; +/// `--follow` 时进入阻塞跟踪循环。 +/// +/// # 步骤 +/// 1. 取 well-known 日志路径;文件不存在时以红色打印退 1(不等待文件出现, +/// `--follow` 同样行为,保持单一)。 +/// 2. 读文件、过滤、逐行打印;io 错误以红色打印退 1。 +/// 3. `--follow` 时进入阻塞跟踪循环(标准输入中断即进程默认行为退出)。 +fn log_command(follow: bool, level: Option, lines: Option) -> ExitCode { + let path = sidecar::well_known_log_path(); + if !path.exists() { + eprintln!( + "{}", + Red.paint(format!("日志文件不存在:{}(server 可能未启动过)", path.display())) + ); + return ExitCode::FAILURE; + } + + match server_log::read_log(&path, level, lines) { + Ok(log_lines) => { + for line in log_lines { + println!("{line}"); + } + } + Err(err) => { + eprintln!("{}", Red.paint(format!("读取日志文件失败:{err}"))); + return ExitCode::FAILURE; + } + } + + if follow { + server_log::follow(&path, level); + } + 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/log.rs b/crates/oxide_cli/src/server/log.rs new file mode 100644 index 00000000..d4a47967 --- /dev/null +++ b/crates/oxide_cli/src/server/log.rs @@ -0,0 +1,247 @@ +//! 日志读取:行级别解析、阈值加最后 N 行过滤、整文件读取、阻塞式跟踪。 +//! +//! 日志文件由 server 进程写入(sidecar 同主名的 `.log` 文件),本模块只读。 +//! 日志行格式固定为 `epoch秒.毫秒 级别 target: 消息`(tracing 紧凑格式加 +//! 毫秒时间戳),级别是第二个空白分隔字段。 + +use std::fs::{self, OpenOptions}; +use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::path::Path; +use std::thread; +use std::time::Duration; + +use oxide_log::Level; + +/// 跟踪循环轮询间隔:500 毫秒。 +const FOLLOW_POLL_INTERVAL: Duration = Duration::from_millis(500); + +/// 行级别解析:取第二个空白分隔字段,映射到 [`Level`]。 +/// +/// # 边界与前提 +/// - `ERROR` / `WARN` / `INFO` / `DEBUG` / `TRACE` 映射到对应级别;其余 +/// (含非日志行)返回 `None`。 +pub fn line_level(line: &str) -> Option { + let field = line.split_whitespace().nth(1)?; + match field { + "ERROR" => Some(Level::Error), + "WARN" => Some(Level::Warn), + "INFO" => Some(Level::Info), + "DEBUG" => Some(Level::Debug), + "TRACE" => Some(Level::Trace), + _ => None, + } +} + +/// 级别阈值加最后 N 行过滤。 +/// +/// # 步骤 +/// 1. 按级别阈值过滤:`None` 不过滤;过滤生效时保留不低于该级别(严重度 +/// 不低于)的行,行级别为 `None` 的行丢弃。 +/// 2. 取最后 N 行:`None` 取全部,`Some(0)` 得空集。 +/// +/// # 边界与前提 +/// - 严重度次序为 `Error` > `Warn` > `Info` > `Debug` > `Trace`(与级别 +/// 枚举值次序相反):阈值 `error` 只留 `ERROR` 行,阈值 `info` 留 +/// `ERROR`/`WARN`/`INFO` 行。 +pub fn filter_tail(lines: &[String], min_level: Option, last_n: Option) -> Vec { + let mut filtered: Vec = match min_level { + Some(min) => lines.iter().filter(|line| line_level(line).is_some_and(|level| level <= min)).cloned().collect(), + None => lines.to_vec(), + }; + match last_n { + Some(n) if n < filtered.len() => filtered.split_off(filtered.len() - n), + _ => filtered, + } +} + +/// 读整文件、按行切分、调 [`filter_tail`] 返回过滤后的行。 +pub fn read_log(path: &Path, min_level: Option, last_n: Option) -> io::Result> { + let text = fs::read_to_string(path)?; + let lines: Vec = text.lines().map(str::to_string).collect(); + Ok(filter_tail(&lines, min_level, last_n)) +} + +/// 阻塞式跟踪到标准输出:CLI 入口。 +/// +/// 标准输入中断即进程默认行为退出,无需额外信号处理。 +pub fn follow(path: &Path, min_level: Option) { + let stdout = io::stdout(); + let mut lock = stdout.lock(); + follow_to(path, min_level, &mut lock); +} + +/// 阻塞式跟踪:从文件末尾起追加读新行,写入给定 writer。 +/// +/// # 步骤 +/// 1. 打开文件并定位到末尾。 +/// 2. 每 500 毫秒轮询一次文件长度;长度小于已读偏移时重置偏移为 0 +/// (文件被截断,如人工清空或未来轮转)。 +/// 3. 读到新字节后只写完整行(末尾无换行的残行留待下轮),按级别阈值 +/// 过滤后逐行写入。 +/// +/// # 边界与前提 +/// - 单文件描述符、无轮询外副作用;进程退出即关闭,无泄漏。 +/// +/// # 副作用 +/// - 向 writer 写行。 +fn follow_to(path: &Path, min_level: Option, writer: &mut W) { + let mut file = OpenOptions::new().read(true).open(path).expect("日志文件应存在(调用方已检查)"); + let mut offset = file.seek(SeekFrom::End(0)).expect("定位日志文件末尾应成功"); + let mut buf = [0u8; 8192]; + let mut pending = String::new(); + + loop { + let len = file.metadata().map(|m| m.len()).unwrap_or(0); + if len < offset { + // 文件被截断(人工清空或轮转):重置偏移从 0 重读。 + offset = 0; + pending.clear(); + file.seek(SeekFrom::Start(0)).expect("定位日志文件开头应成功"); + } + + if len > offset { + let mut to_read = len - offset; + let mut chunk = Vec::new(); + while to_read > 0 { + let n = file.read(&mut buf).expect("读日志文件应成功"); + if n == 0 { + break; + } + chunk.extend_from_slice(&buf[..n]); + to_read -= n as u64; + } + offset += chunk.len() as u64; + pending.push_str(&String::from_utf8_lossy(&chunk)); + + // 只写完整行:末尾无换行的残行留待下轮。 + if let Some(pos) = pending.rfind('\n') { + let complete: String = pending.drain(..=pos).collect(); + for line in complete.lines() { + if level_allowed(line, min_level) { + let _ = writeln!(writer, "{line}"); + } + } + } + } + + thread::sleep(FOLLOW_POLL_INTERVAL); + } +} + +/// 行是否通过级别阈值(`None` 不过滤):保留严重度不低于该级别的行。 +fn level_allowed(line: &str, min_level: Option) -> bool { + match min_level { + Some(min) => line_level(line).is_some_and(|level| level <= min), + None => true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + /// temp_dir 下的唯一临时文件(进程号加纳秒时间戳),与 sidecar 测试惯例一致。 + fn temp_file(name: &str) -> PathBuf { + let ns = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + std::env::temp_dir().join(format!("oxide_log_test_{}_{}_{}", std::process::id(), name, ns)) + } + + /// 行级别解析:五级映射与非日志行返回 `None`。 + #[test] + fn line_level_parses_five_levels_and_rejects_other() { + assert_eq!(line_level("1700000000.123 INFO oxide::kernel: ready"), Some(Level::Info)); + assert_eq!(line_level("1700000000.123 ERROR oxide::vm: boom"), Some(Level::Error)); + assert_eq!(line_level("1700000000.123 WARN oxide::ic: miss"), Some(Level::Warn)); + assert_eq!(line_level("1700000000.123 DEBUG oxide::vm: dbg"), Some(Level::Debug)); + assert_eq!(line_level("1700000000.123 TRACE oxide::vm: trace"), Some(Level::Trace)); + assert_eq!(line_level("not a log line at all"), None); + assert_eq!(line_level(""), None); + } + + /// filter_tail 阈值语义(严重度不低于该级别的行:error 只留 ERROR、 + /// info 留 INFO 与更严重、debug 留 DEBUG 与更严重、trace 全留、None + /// 不过滤)与最后 N 行语义(N 大于行数取全部、N 为 0 空集)。 + #[test] + fn filter_tail_threshold_and_tail() { + let lines = vec![ + "1700000000.001 INFO a: one".into(), + "1700000000.002 ERROR a: two".into(), + "1700000000.003 DEBUG a: three".into(), + "1700000000.004 WARN a: four".into(), + ]; + // error 只留 ERROR 行。 + assert_eq!(filter_tail(&lines, Some(Level::Error), None), vec!["1700000000.002 ERROR a: two"]); + // info 留 ERROR/WARN/INFO 行,丢 DEBUG 行。 + assert_eq!( + filter_tail(&lines, Some(Level::Info), None), + vec![ + "1700000000.001 INFO a: one", + "1700000000.002 ERROR a: two", + "1700000000.004 WARN a: four", + ] + ); + // debug 留 DEBUG 与更严重行(全留);trace 全留。 + assert_eq!(filter_tail(&lines, Some(Level::Debug), None), lines); + assert_eq!(filter_tail(&lines, Some(Level::Trace), None), lines); + assert_eq!(filter_tail(&lines, None, None), lines); + assert_eq!( + filter_tail(&lines, None, Some(2)), + vec!["1700000000.003 DEBUG a: three", "1700000000.004 WARN a: four"] + ); + assert_eq!(filter_tail(&lines, None, Some(10)), lines); + assert_eq!(filter_tail(&lines, None, Some(0)), Vec::::new()); + } + + /// read_log 读临时文件并按阈值过滤(info 丢 DEBUG 行)。 + #[test] + fn read_log_reads_and_filters() { + let path = temp_file("read"); + fs::write(&path, "1700000000.001 INFO a: one\n1700000000.002 DEBUG a: two\n").expect("写临时文件应成功"); + let lines = read_log(&path, Some(Level::Info), None).expect("读日志应成功"); + assert_eq!(lines, vec!["1700000000.001 INFO a: one"]); + let _ = fs::remove_file(&path); + } + + /// 测试 writer:把每行写入转发到通道。 + struct LineSender(std::sync::mpsc::Sender); + + impl Write for LineSender { + fn write(&mut self, buf: &[u8]) -> io::Result { + // follow_to 每次写入一行完整日志(含换行)。 + let text = String::from_utf8_lossy(buf); + for line in text.lines() { + self.0.send(line.to_string()).expect("通道不应关闭"); + } + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + /// follow 跟踪:先写一行,起跟踪线程,再追加一行,经通道断言收到该行 + /// (500 毫秒轮询窗口为上限,2 秒接收窗口避免超时抖动)。 + #[test] + fn follow_receives_appended_line() { + let path = temp_file("follow"); + fs::write(&path, "1700000000.001 INFO a: first\n").expect("写临时文件应成功"); + let (tx, rx) = std::sync::mpsc::channel::(); + let path_clone = path.clone(); + thread::spawn(move || { + let mut sink = LineSender(tx); + follow_to(&path_clone, None, &mut sink); + }); + + // 给跟踪线程时间打开文件并定位末尾。 + thread::sleep(Duration::from_millis(200)); + let mut file = OpenOptions::new().append(true).open(&path).expect("追加打开应成功"); + file.write_all(b"1700000000.002 INFO a: second\n").expect("追加应成功"); + + let line = rx.recv_timeout(Duration::from_millis(2000)).expect("2 秒内应收到追加行"); + assert_eq!(line, "1700000000.002 INFO a: second"); + let _ = fs::remove_file(&path); + } +} diff --git a/crates/oxide_cli/src/server/mod.rs b/crates/oxide_cli/src/server/mod.rs index da6db474..2f876af9 100644 --- a/crates/oxide_cli/src/server/mod.rs +++ b/crates/oxide_cli/src/server/mod.rs @@ -2,12 +2,13 @@ //! //! 持久 server 是常驻进程,复用预热池、近零 spawn 成本; //! 通信走 Unix socket + NDJSON(换行分隔 JSON)。 -//! 模块分六层:协议数据面(帧格式与 serde 结构体)、身份注册(sidecar 文件 +//! 模块分七层:协议数据面(帧格式与 serde 结构体)、身份注册(sidecar 文件 //! 与 liveness 检查原语)、控制客户端(读 sidecar、连接 server、发控制请求 //! 帧、读回响应帧、三分支错误)、执行请求路径(parse → compile → spawn → //! run → format → drop 六段纯函数)、worker 池(固定 N 常驻 worker、各持 //! 自有 VM 池、mpsc 轮询路由)、server 进程主体(accept 循环、控制请求直接 -//! 处理、执行请求路由、优雅退出)。 +//! 处理、执行请求路由、优雅退出)、日志读取(行级别解析、阈值加最后 N 行 +//! 过滤、阻塞式跟踪)。 /// 人工兜底清理命令:扫 sidecar、探活、杀 PID、删孤儿文件。 pub mod cleanup; @@ -15,6 +16,8 @@ pub mod cleanup; pub mod client; /// 执行请求路径:parse → compile → spawn → run → format → drop 六段纯函数。 pub mod eval; +/// 日志读取:行级别解析、阈值加最后 N 行过滤、整文件读取、阻塞式跟踪。 +pub mod log; /// 协议数据面:NDJSON 帧格式与 serde 结构体。 pub mod protocol; /// server 进程主体:accept 循环、控制请求分派、执行请求路由、信号处理 diff --git a/crates/oxide_cli/src/server/server.rs b/crates/oxide_cli/src/server/server.rs index a3c401af..f28dbad8 100644 --- a/crates/oxide_cli/src/server/server.rs +++ b/crates/oxide_cli/src/server/server.rs @@ -29,6 +29,7 @@ use std::thread::JoinHandle; use std::time::{Duration, Instant}; use oxide_kernel::kernel::{KernelConfig, KernelCore}; +use oxide_log::{Level, LogConfig, Output, SUBSYSTEM_COUNT}; use oxide_vm::vm_pool::PoolCounters; use super::protocol::{self, FrameReader, ProtocolError, ServerRequest, ServerResponse}; @@ -146,9 +147,9 @@ struct ServerContext { socket_path: String, } -/// server 进程主体:认领 sidecar → 注册信号处理器 → 绑定 socket → 建内核 → -/// 派生 worker → accept 循环 → 在途归零 → 清空发送端 → join worker → -/// drop 内核 → 先删 socket 文件后删 sidecar。 +/// server 进程主体:认领 sidecar → 注册信号处理器 → 绑定 socket → +/// 初始化日志文件输出 → 建内核 → 派生 worker → accept 循环 → 在途归零 → +/// 清空发送端 → join worker → drop 内核 → 先删 socket 文件后删 sidecar。 /// /// # 步骤 /// 1. 认领 sidecar(启动顺序契约:先写 sidecar 再 bind socket);已有同版本 @@ -156,9 +157,11 @@ struct ServerContext { /// yield 请求,等旧 server 排空退出后重新认领)。 /// 2. 注册信号处理器(绑定 socket 之前;失败回滚 sidecar,此时无存活线程)。 /// 3. 绑定监听器;失败且路径存在时删 stale socket 文件重试一次。 -/// 4. 建内核(标准配置)与共享聚合计数器。 -/// 5. 派生 N 个常驻 worker(各在自己线程上建自有池)。 -/// 6. 进入 accept 循环;关闭标志置位后按关闭序列退出。 +/// 4. 初始化日志文件输出(sidecar 同主名的追加文件、Info 级;init 幂等, +/// 内核构造内部的重复调用是空操作)。 +/// 5. 建内核(标准配置)与共享聚合计数器。 +/// 6. 派生 N 个常驻 worker(各在自己线程上建自有池)。 +/// 7. 进入 accept 循环;关闭标志置位后按关闭序列退出。 /// /// # 边界与前提 /// - 认领得 `AlreadyRunning`(同版本)立即返回 `ClaimRefused`,不交接。 @@ -170,6 +173,8 @@ struct ServerContext { /// /// # 副作用 /// - 创建 sidecar 与 socket 文件;正常退出时删除两者。 +/// - 创建日志文件(追加写、sidecar 同主名);正常退出时不删除(跨重启的 +/// 诊断工件)。 /// - 注册进程级信号处理器(SIGINT、SIGTERM,termination 特性下含 SIGHUP)。 /// - 派生 N 个常驻 worker 线程与每连接一个 I/O 线程。 /// @@ -200,6 +205,15 @@ pub fn run_server(config: &ServerConfig) -> Result<(), ServerError> { // 绑定监听器:stale socket 文件删一次重试,失败回滚 sidecar。 let listener = bind_listener(config)?; + // 日志文件:sidecar 同主名的追加文件,先于内核构造初始化(init 幂等, + // 内核构造内部的重复调用是空操作;顺序颠倒则输出回落环境变量路径, + // 日志文件为空)。 + let log_path = config.sidecar_path.with_extension("log"); + oxide_log::init(&LogConfig { + output: Output::FileExact(log_path), + levels: [Level::Info; SUBSYSTEM_COUNT], + }); + // 建内核(标准配置)与共享聚合计数器(全体 worker 池的增减累加到同一聚合)。 let kernel = KernelCore::new(KernelConfig::standard()); let counters = PoolCounters::shared(); diff --git a/crates/oxide_cli/src/server/sidecar.rs b/crates/oxide_cli/src/server/sidecar.rs index 1c013a94..c1eb2cff 100644 --- a/crates/oxide_cli/src/server/sidecar.rs +++ b/crates/oxide_cli/src/server/sidecar.rs @@ -8,6 +8,8 @@ //! PID 探活为次信号,PID 复用以启动时刻刻度交叉核对排除。 //! - --rm 模式走独立分支:生成唯一 socket 路径、不写 sidecar、不碰全局 //! well-known 路径;该不变量由运行路径执行。 +//! - 日志文件由 sidecar 路径按扩展名派生(`.log`),与 sidecar、socket 同主名 +//! 同目录;有意不随退出删除(跨重启的诊断工件),不在清理命令范围内。 use std::fs::{self, OpenOptions}; use std::io::{self, Write}; @@ -86,6 +88,11 @@ pub fn well_known_socket_path() -> PathBuf { std::env::temp_dir().join(format!("oxide-{}.sock", current_uid())) } +/// well-known 日志文件路径:`$TMPDIR/oxide-.log`(与 sidecar 同主名)。 +pub fn well_known_log_path() -> PathBuf { + well_known_sidecar_path().with_extension("log") +} + /// O_EXCL 原子创建 sidecar(文件已存在时创建失败)。 /// /// # 步骤 @@ -460,4 +467,14 @@ mod tests { assert!(file_name.contains(&std::process::id().to_string()), "文件名应含本进程 PID"); assert!(file_name.starts_with("oxide-rm-"), "文件名应以 oxide-rm- 前缀开头"); } + + /// 日志路径:`.log` 扩展名,与 sidecar 路径同主名同目录。 + #[test] + fn well_known_log_path_derives_from_sidecar() { + let log = well_known_log_path(); + let sidecar = well_known_sidecar_path(); + assert_eq!(log.extension().and_then(|e| e.to_str()), Some("log"), "日志路径应以 .log 结尾"); + assert_eq!(log.file_stem(), sidecar.file_stem(), "日志与 sidecar 应同主名"); + assert_eq!(log.parent(), sidecar.parent(), "日志与 sidecar 应同目录"); + } } diff --git a/crates/oxide_cli/tests/server_cli.rs b/crates/oxide_cli/tests/server_cli.rs index a4cc659c..9ef2df33 100644 --- a/crates/oxide_cli/tests/server_cli.rs +++ b/crates/oxide_cli/tests/server_cli.rs @@ -48,13 +48,13 @@ fn server_help_lists_subcommands() { } } -/// 两个占位臂各退 2,stderr 含 not yet implemented。 +/// 占位臂退 2,stderr 含 not yet implemented。 /// -/// version/status/health/info/stop/restart 六臂已是真实实现(无 server 时退 1), -/// 不在此列;log/forge 仍是占位。 +/// version/status/health/info/stop/restart/log 七臂已是真实实现(无 server +/// 时退 1),不在此列;forge 仍是占位。 #[test] fn server_stub_subcommands_exit_2() { - for sub in ["log", "forge"] { + for sub in ["forge"] { let output = oxide(&["server", sub]); assert_eq!(output.status.code(), Some(2), "{sub} 应退 2"); let stderr = String::from_utf8_lossy(&output.stderr); @@ -62,6 +62,27 @@ fn server_stub_subcommands_exit_2() { } } +/// log 子命令无日志文件时退 1,stderr 含「日志文件不存在」。 +/// +/// 走 well-known 全局路径:先探活,存活 server 占用时 panic 不抢占;删除 +/// well-known 日志文件(无存活 server 时删除安全)。 +#[test] +fn server_log_no_file_exit_1() { + let _guard = WELL_KNOWN_LOCK.lock().expect("全局路径锁不应中毒"); + assert!( + !sidecar::is_server_alive(&sidecar::well_known_socket_path()), + "存活 server 占用 well-known 路径,测试不抢占" + ); + + let log_path = sidecar::well_known_log_path(); + let _ = std::fs::remove_file(&log_path); + + let output = oxide(&["server", "log"]); + assert_eq!(output.status.code(), Some(1), "log 应退 1"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("日志文件不存在"), "log 应打印无日志文件提示:{stderr}"); +} + /// 五个控制臂无 server 时各退 1,stderr 含「无已注册的 server」提示。 /// /// 走 well-known 全局路径:先探活,存活 server 占用时 panic 不抢占。 @@ -264,3 +285,85 @@ fn server_restart_e2e() { assert!(!socket.exists(), "socket 文件应被删除"); assert!(!sidecar_path.exists(), "sidecar 文件应被删除"); } + +/// log 端到端:守护形态 start 后轮询日志文件出现「KernelCore initialized」 +/// 行判就绪(socket 文件出现早于内核构造,不能直接作为日志就绪信号), +/// 断言 `log` 退 0 且含该行、`--lines 1` 恰一行、`--level error` 为空 +/// (Info 级无 ERROR 行),关闭后日志文件有意保留。 +#[test] +fn server_log_e2e() { + let _guard = WELL_KNOWN_LOCK.lock().expect("全局路径锁不应中毒"); + 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(&socket), "存活 server 占用 well-known 路径,测试不抢占"); + + // 删旧日志文件:server 即将全新启动,内容断言不受历史污染。 + let _ = std::fs::remove_file(&log_path); + + // start 为守护形态:CLI 子进程即刻退 0 并打印「已启动」,server 是脱离 + // 的孙进程(其日志初始化在孙进程内确定性生效,内容断言无竞态)。 + let output = oxide(&["server", "start", "--workers", "2"]); + assert_eq!(output.status.code(), Some(0), "start 应退 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("已启动"), "start 应打印「已启动」:{stdout}"); + + // 读 sidecar 取 server 进程号。 + let id = sidecar::read_identity(&sidecar_path).expect("读 sidecar 应成功"); + + // 轮询日志文件出现「KernelCore initialized」行判就绪(30 秒截止、 + // 50 毫秒间隔)。 + let deadline = Instant::now() + Duration::from_secs(30); + let content = loop { + let content = std::fs::read_to_string(&log_path).unwrap_or_default(); + if content.contains("KernelCore initialized") { + break content; + } + assert!(Instant::now() < deadline, "日志文件应在 30 秒内含内核初始化行"); + std::thread::sleep(Duration::from_millis(50)); + }; + assert!(!content.is_empty(), "日志文件内容应非空"); + + // log:退 0 且含内核初始化行。 + let output = oxide(&["server", "log"]); + assert_eq!(output.status.code(), Some(0), "log 应退 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("KernelCore initialized"), "log 应含内核初始化行:{stdout}"); + + // --lines 1:恰一行。 + let output = oxide(&["server", "log", "--lines", "1"]); + assert_eq!(output.status.code(), Some(0), "log --lines 1 应退 0"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout.lines().count(), 1, "log --lines 1 应恰一行:{stdout}"); + + // --level error:空(Info 级无 ERROR 行)。 + let output = oxide(&["server", "log", "--level", "error"]); + assert_eq!(output.status.code(), Some(0), "log --level error 应退 0"); + assert!( + output.stdout.is_empty(), + "log --level error 应为空:{}", + String::from_utf8_lossy(&output.stdout) + ); + + // 关闭请求帧:读回关闭确认帧。 + let mut stream = UnixStream::connect(&socket).expect("连接 server 应成功"); + let response = send_and_recv(&mut stream, &ServerRequest::Shutdown); + assert!(matches!(response, ServerResponse::Shutdown), "应得关闭确认帧:{response:?}"); + drop(stream); + + // 轮询 sidecar 文件消失(30 秒截止、50 毫秒间隔)判 server 进程已退出。 + 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!(!sidecar::pid_alive(id.pid), "server 进程应已退出"); + + // 日志文件有意保留(跨重启的诊断工件)。 + assert!(log_path.exists(), "日志文件退出后应保留"); + + // 收尾:删除日志文件恢复状态。 + let _ = std::fs::remove_file(&log_path); +} diff --git a/crates/oxide_log/src/init.rs b/crates/oxide_log/src/init.rs index 5b72bee0..74ba2c5e 100644 --- a/crates/oxide_log/src/init.rs +++ b/crates/oxide_log/src/init.rs @@ -1,11 +1,13 @@ //! 日志初始化。 //! //! `init` 一次性初始化全局 `tracing` subscriber,输出可定向到 stderr / stdout / -//! 按日滚动的文件;[`set_level`] 与 `get_subsystem` 提供运行期级别控制。 +//! 按日滚动的文件 / 追加写的精确文件;[`set_level`] 与 `get_subsystem` 提供运行期 +//! 级别控制。 //! 重复调用 `init` 幂等(内部 `Once`),后续调用不生效。 //! 时间戳统一毫秒精度(`MillisTime`,`epoch秒.毫秒`),供 GC 周期关联与 //! 跨进程现场拼接。 +use std::fs::OpenOptions; use std::io; use std::path::PathBuf; use std::sync::{Once, OnceLock}; @@ -42,6 +44,8 @@ pub enum Output { Stdout, /// 按日滚动写入 `dir` 目录下的 `oxide.log`。 File(PathBuf), + /// 追加写入指定文件(不滚动)。 + FileExact(PathBuf), } /// 日志初始化配置。 @@ -146,6 +150,26 @@ pub fn init(config: &LogConfig) { .with(file_layer.with_filter(filter)); subscriber.init(); } + Output::FileExact(path) => { + // 同步写:低频诊断事件,不引入 non_blocking 与 FILE_GUARD 全局槽。 + // 打开失败响亮失败:静默回落 stderr 会让日志文件永远为空,更难诊断。 + let file = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .expect("打开日志文件失败:server 启动时打开自家临时目录下的文件失败是异常情形"); + + let file_layer = tracing_subscriber::fmt::Layer::default() + .with_writer(file) + .with_ansi(false) + .with_timer(MillisTime) + .compact(); + + let subscriber = tracing_subscriber::Registry::default() + .with(env_filter) + .with(file_layer.with_filter(filter)); + subscriber.init(); + } } }); }