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 应同目录");
}
}