feat: server log 子命令,日志落地 well-known 文件加三旗标读取

This commit is contained in:
2026-10-06 18:25:47 +00:00
parent 176c7dffe1
commit 83e41e10ab
9 changed files with 516 additions and 37 deletions
+76 -5
View File
@@ -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<LogLevel>,
/// 显示最后 N 行(缺省全部)。
#[arg(long)]
lines: Option<usize>,
},
/// 查询内部状态(代码、对象、字符串、属性)。
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<Level>, lines: Option<usize>) -> 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 {
+247
View File
@@ -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<Level> {
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<Level>, last_n: Option<usize>) -> Vec<String> {
let mut filtered: Vec<String> = 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<Level>, last_n: Option<usize>) -> io::Result<Vec<String>> {
let text = fs::read_to_string(path)?;
let lines: Vec<String> = text.lines().map(str::to_string).collect();
Ok(filter_tail(&lines, min_level, last_n))
}
/// 阻塞式跟踪到标准输出:CLI 入口。
///
/// 标准输入中断即进程默认行为退出,无需额外信号处理。
pub fn follow(path: &Path, min_level: Option<Level>) {
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<W: Write>(path: &Path, min_level: Option<Level>, 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<Level>) -> 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::<String>::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<String>);
impl Write for LineSender {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
// 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::<String>();
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);
}
}
+5 -2
View File
@@ -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 循环、控制请求分派、执行请求路由、信号处理
+20 -6
View File
@@ -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();
+17
View File
@@ -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-<uid>.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 应同目录");
}
}
+107 -4
View File
@@ -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);
}
+25 -1
View File
@@ -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();
}
}
});
}