Files
OxideJS-New/crates/oxide_cli/tests/server_cli.rs
T

617 lines
29 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! server CLI 集成测试:真实二进制的分派面(help 列子命令、无 server 时
//! 控制臂与 forge 退 1、cleanup 幂等、start 与 restart 端到端、forge 端到端、
//! watchdog 崩溃自动重启与 SIGINT 优雅停 server)。
//!
//! start / restart / watchdog 端到端与 cleanup 走 well-known 全局路径
//! (每用户单例):测试先探活,socket 存活即 panic 不抢占存活 server;
//! 触碰全局路径的测试经同一把锁串行,避免相互干扰。
use std::io::{BufRead, 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::client::send_control_request;
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")
}
/// 向进程发信号(与 sidecar liveness 探活同一 `kill` 二进制路径)。
fn kill_process(pid: u32, signal: &str) {
Command::new("kill")
.args([signal, &pid.to_string()])
.status()
.expect("发送信号应成功");
}
/// 轮询健康请求直至得 healthy(100 毫秒间隔、给定截止)。
///
/// sidecar 出现早于 socket 绑定与池预热,不能直接作为就绪信号;watchdog
/// 的初始就绪探测与测试的崩溃模拟都以健康请求通过为界。
fn wait_for_healthy(deadline: Duration) {
let end = Instant::now() + deadline;
loop {
let ready =
matches!(send_control_request(&ServerRequest::Health), Ok(ServerResponse::Health { healthy: true }));
if ready {
return;
}
assert!(Instant::now() < end, "server 应在有界时间内就绪");
std::thread::sleep(Duration::from_millis(100));
}
}
/// 轮询判 server 进程退出(50 毫秒间隔、给定截止)。
///
/// 删 sidecar 文件早于进程实际退出,二者间隙随构建环境变化(带内存
/// sanitizer 的构建 atexit 收尾更久),立即断言会撞上未退出窗口,故有界轮询。
fn wait_pid_dead(pid: u32, deadline: Duration) {
let end = Instant::now() + deadline;
while sidecar::pid_alive(pid) {
assert!(Instant::now() < end, "server 进程应在有界时间内退出");
std::thread::sleep(Duration::from_millis(50));
}
}
/// 发送请求帧并读回一帧响应。
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", "watchdog",
] {
assert!(stdout.contains(name), "help 应列出 {name}:{stdout}");
}
}
/// 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 不抢占。
#[test]
fn server_control_arms_no_server_exit_1() {
let _guard = WELL_KNOWN_LOCK.lock().expect("全局路径锁不应中毒");
assert!(
!sidecar::is_server_alive(&sidecar::well_known_socket_path()),
"存活 server 占用 well-known 路径,测试不抢占"
);
for sub in ["stop", "status", "health", "info", "version"] {
let output = oxide(&["server", sub]);
assert_eq!(output.status.code(), Some(1), "{sub} 应退 1");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("无已注册的 server"), "{sub} 应打印无 server 提示:{stderr}");
}
}
/// forge 子命令无 server 时退 1,stderr 含「无已注册的 server」提示。
///
/// 走 well-known 全局路径:先探活,存活 server 占用时 panic 不抢占。
#[test]
fn server_forge_no_server_exit_1() {
let _guard = WELL_KNOWN_LOCK.lock().expect("全局路径锁不应中毒");
assert!(
!sidecar::is_server_alive(&sidecar::well_known_socket_path()),
"存活 server 占用 well-known 路径,测试不抢占"
);
let output = oxide(&["server", "forge", "code"]);
assert_eq!(output.status.code(), Some(1), "forge 应退 1");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("无已注册的 server"), "forge 应打印无 server 提示:{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 端到端:CLI 即刻返回(server 是脱离的孙进程),断言退 0 且打印
/// 「已启动」,well-known 路径连接,版本帧断言构建期版本,关闭帧后 server
/// 进程退出,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 路径,测试不抢占");
// start 为守护形态:CLI 即刻返回,server 是脱离的孙进程;worker 数压到 2
// 避免按宿主核数预热的成本。
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 应成功");
// 版本请求帧:版本应等于构建期版本。
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);
// 轮询 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!(!socket.exists(), "socket 文件应被删除");
assert!(!sidecar_path.exists(), "sidecar 文件应被删除");
wait_pid_dead(id.pid, Duration::from_secs(30));
}
/// start 双启动幂等:已有存活 server 时再跑 start,断言第二次退 0 且打印
/// 「已启动」(第二个 spawn 的子进程认领被拒退 1 不触碰文件,探测看到第一个
/// server 即通过)。
#[test]
fn server_start_when_running() {
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 路径,测试不抢占");
// 首次 start:CLI 即刻返回,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}");
// 二次 start:第二个 spawn 的子进程认领被拒,探测看到第一个 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 消失。
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);
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!(!socket.exists(), "socket 文件应被删除");
assert!(!sidecar_path.exists(), "sidecar 文件应被删除");
}
/// restart 端到端:起真实子进程 A,跑 `oxide server restart`(发关闭、等退出、
/// 拉起新 server、就绪探测),断言退 0 且打印「已重启」,旧进程退 0,新 sidecar
/// 进程号不同且版本为构建期版本,新 server 健康与版本请求正常,收尾关闭后文件被删。
#[test]
fn server_restart_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 路径,测试不抢占");
// 起子进程 A:worker 数压到 2,轮询 socket 文件出现(10 秒截止)判就绪。
let mut child = Command::new(env!("CARGO_BIN_EXE_oxide"))
.args(["server", "start", "--workers", "2"])
.spawn()
.expect("oxide server start 应可启动");
let deadline = Instant::now() + Duration::from_secs(10);
while !socket.exists() {
assert!(Instant::now() < deadline, "socket 文件未出现");
std::thread::sleep(Duration::from_millis(10));
}
// 读 sidecar 取旧进程号。
let old_id = sidecar::read_identity(&sidecar_path).expect("读旧 sidecar 应成功");
// 跑 restart:断言退 0、stdout 含「已重启」。
let output = oxide(&["server", "restart"]);
assert_eq!(output.status.code(), Some(0), "restart 应退 0");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("已重启"), "restart 应打印「已重启」:{stdout}");
// 断言子进程 A 以退出码 0 退出(30 秒截止,try_wait 轮询)。
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}"),
}
}
// 断言新 sidecar 存在、进程号与旧进程号不同、版本等于构建期版本。
let new_id = sidecar::read_identity(&sidecar_path).expect("读新 sidecar 应成功");
assert_ne!(new_id.pid, old_id.pid, "新 server 进程号应不同于旧 server");
assert_eq!(new_id.version, env!("CARGO_PKG_VERSION"), "新 sidecar 版本应与构建期版本一致");
// 连接新 server:健康请求得 healthy,版本请求得构建期版本。
let mut stream = UnixStream::connect(&socket).expect("连接新 server 应成功");
match send_and_recv(&mut stream, &ServerRequest::Health) {
ServerResponse::Health { healthy } => assert!(healthy, "新 server 应健康"),
other => panic!("应得 Health 帧,实得 {other:?}"),
}
match send_and_recv(&mut stream, &ServerRequest::Version) {
ServerResponse::Version { version } => {
assert_eq!(version, env!("CARGO_PKG_VERSION"), "版本应与构建期版本一致");
}
other => panic!("应得 Version 帧,实得 {other:?}"),
}
// 收尾:发关闭请求,等新 server 退出,断言 socket 与 sidecar 文件被删。
let response = send_and_recv(&mut stream, &ServerRequest::Shutdown);
assert!(matches!(response, ServerResponse::Shutdown), "应得关闭确认帧:{response:?}");
drop(stream);
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!(!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));
}
wait_pid_dead(id.pid, Duration::from_secs(30));
// 日志文件有意保留(跨重启的诊断工件)。
assert!(log_path.exists(), "日志文件退出后应保留");
// 收尾:删除日志文件恢复状态。
let _ = std::fs::remove_file(&log_path);
}
/// forge 端到端:守护形态 start 后,`forge code` 打印条目数与容量行,
/// `forge string --lookup` 打印 lookup 行,`forge code --gc --clear-cache`
/// 打印 gc 与清缓存行;关闭后 socket 与 sidecar 文件被删。
#[test]
fn server_forge_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 路径,测试不抢占");
// start 为守护形态:CLI 即刻返回,server 是脱离的孙进程;轮询 socket 文件
// 出现判就绪(worker 数压到 2 避免按宿主核数预热的成本)。
let output = oxide(&["server", "start", "--workers", "2"]);
assert_eq!(output.status.code(), Some(0), "start 应退 0");
let deadline = Instant::now() + Duration::from_secs(10);
while !socket.exists() {
assert!(Instant::now() < deadline, "socket 文件未出现");
std::thread::sleep(Duration::from_millis(10));
}
// forge code:退 0,stdout 含条目数行与容量行。
let output = oxide(&["server", "forge", "code"]);
assert_eq!(output.status.code(), Some(0), "forge code 应退 0");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("code forge:"), "forge code 应打印条目数行:{stdout}");
assert!(stdout.contains("capacity: 512"), "forge code 应打印容量行:{stdout}");
// forge string --lookup:退 0,stdout 含 lookup 行。
let output = oxide(&["server", "forge", "string", "--lookup", "forge-e2e-key"]);
assert_eq!(output.status.code(), Some(0), "forge string --lookup 应退 0");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("lookup:"), "forge --lookup 应打印 lookup 行:{stdout}");
// forge code --gc --clear-cache:退 0,stdout 含 gc 与清缓存行。
let output = oxide(&["server", "forge", "code", "--gc", "--clear-cache"]);
assert_eq!(output.status.code(), Some(0), "forge code --gc --clear-cache 应退 0");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("gc: collected"), "forge --gc 应打印 gc 行:{stdout}");
assert!(stdout.contains("cache cleared"), "forge --clear-cache 应打印清缓存行:{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!(!socket.exists(), "socket 文件应被删除");
assert!(!sidecar_path.exists(), "sidecar 文件应被删除");
}
/// watchdog 端到端:拉起 watchdog 前台进程(其拉起 server),`kill -9` 模拟
/// 崩溃,断言 watchdog 自动重启(新 sidecar 进程号不同、新 server 健康),
/// 关闭后 watchdog 退 0 且 stdout 含崩溃提示与日志尾部。
#[test]
fn server_watchdog_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 路径,测试不抢占");
// 拉起 watchdog 前台进程(stdout 管道捕获崩溃诊断输出)。
let mut child = Command::new(env!("CARGO_BIN_EXE_oxide"))
.args(["server", "watchdog", "--workers", "2"])
.stdout(std::process::Stdio::piped())
.spawn()
.expect("oxide server watchdog 应可启动");
// 后台线程消费 stdout 收集全部行,见到「开始监控」标记行即通知:
// 该标记行蕴含 watchdog 已越过初始就绪探测,崩溃模拟在此之后进行
// 才不与初始探测窗口竞争(预热期间杀 server 会让探测超时退 1、不重启)。
let stdout_pipe = child.stdout.take().expect("stdout 管道应可取");
let (monitoring_tx, monitoring_rx) = std::sync::mpsc::channel::<()>();
let out_handle = std::thread::spawn(move || {
let mut reader = BufReader::new(stdout_pipe);
let mut lines = Vec::new();
let mut line = String::new();
loop {
line.clear();
if reader.read_line(&mut line).unwrap_or(0) == 0 {
break;
}
lines.push(line.clone());
if line.contains("watchdog 开始监控") {
let _ = monitoring_tx.send(());
}
}
lines
});
// 轮询 sidecar 出现(10 秒截止)判 server 已启动。
let deadline = Instant::now() + Duration::from_secs(10);
while sidecar::read_identity(&sidecar_path).is_none() {
assert!(Instant::now() < deadline, "sidecar 未出现");
std::thread::sleep(Duration::from_millis(50));
}
let first_id = sidecar::read_identity(&sidecar_path).expect("读 sidecar 应成功");
// 等 watchdog 越过初始就绪探测进入监控循环(30 秒截止)。
monitoring_rx
.recv_timeout(Duration::from_secs(30))
.expect("watchdog 应在有界时间内进入监控循环");
// 模拟崩溃:kill -9(文件残留、无退出序列,与 panic=abort 消亡等价)。
kill_process(first_id.pid, "-9");
// 轮询新 sidecar 出现且进程号不同(30 秒截止、50 毫秒间隔)判 watchdog
// 已重启。
let deadline = Instant::now() + Duration::from_secs(30);
let second_id = loop {
if let Some(id) = sidecar::read_identity(&sidecar_path) {
if id.pid != first_id.pid {
break id;
}
}
assert!(Instant::now() < deadline, "watchdog 应在有界时间内重启 server");
std::thread::sleep(Duration::from_millis(50));
};
// 等 watchdog 越过重启就绪探测(30 秒截止):关闭请求发给探测完成前的
// 新 server 会让它优雅退出、删 sidecar,与 watchdog 的重启探测竞争
// (探测超时退 1、不重启)。
monitoring_rx
.recv_timeout(Duration::from_secs(30))
.expect("watchdog 应在有界时间内完成重启探测");
// 连接新 server:健康请求得 healthy。
let mut stream = UnixStream::connect(&socket).expect("连接新 server 应成功");
match send_and_recv(&mut stream, &ServerRequest::Health) {
ServerResponse::Health { healthy } => assert!(healthy, "新 server 应健康"),
other => panic!("应得 Health 帧,实得 {other:?}"),
}
// 发关闭请求得确认帧,轮询 sidecar 消失(watchdog 判 Exited 退 0)。
let response = send_and_recv(&mut stream, &ServerRequest::Shutdown);
assert!(matches!(response, ServerResponse::Shutdown), "应得关闭确认帧:{response:?}");
drop(stream);
let deadline = Instant::now() + Duration::from_secs(30);
while sidecar_path.exists() {
assert!(Instant::now() < deadline, "server 应在有界时间内退出");
std::thread::sleep(Duration::from_millis(50));
}
// 等 watchdog 子进程退出断言退出码 0,收集 stdout 断言含崩溃提示与日志尾部。
let status = child.wait().expect("等待 watchdog 应成功");
assert_eq!(status.code(), Some(0), "watchdog 应以退出码 0 退出:{status:?}");
let lines = out_handle.join().expect("stdout 读取线程应可汇合");
let stdout = lines.join("");
assert!(stdout.contains("server 已崩溃"), "watchdog 应打印崩溃提示:{stdout}");
assert!(stdout.contains("日志尾部"), "watchdog 应打印日志尾部:{stdout}");
assert_ne!(second_id.pid, first_id.pid, "重启后的 server 进程号应不同");
}
/// watchdog SIGINT:拉起 watchdog 前台进程后向其发 SIGINT,断言 watchdog 退 0、
/// sidecar 消失、server 进程已退出(停 watchdog 即优雅停 server)。
#[test]
fn server_watchdog_sigint_stops_server() {
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 路径,测试不抢占");
// 拉起 watchdog 前台进程。
let mut child = Command::new(env!("CARGO_BIN_EXE_oxide"))
.args(["server", "watchdog", "--workers", "2"])
.spawn()
.expect("oxide server watchdog 应可启动");
// 轮询 sidecar 出现(10 秒截止)判 server 已启动。
let deadline = Instant::now() + Duration::from_secs(10);
while sidecar::read_identity(&sidecar_path).is_none() {
assert!(Instant::now() < deadline, "sidecar 未出现");
std::thread::sleep(Duration::from_millis(50));
}
let id = sidecar::read_identity(&sidecar_path).expect("读 sidecar 应成功");
// 等健康请求通过判 server 就绪:watchdog 已越过初始就绪探测进入监控
// 循环,SIGINT 的优雅关闭请求发给已就绪的 server 即被处理。
wait_for_healthy(Duration::from_secs(60));
// 向 watchdog 发 SIGINT。
kill_process(child.id(), "-INT");
// 等 watchdog 退出断言退出码 0。
let status = child.wait().expect("等待 watchdog 应成功");
assert_eq!(status.code(), Some(0), "watchdog 应以退出码 0 退出:{status:?}");
// server 被优雅停止:sidecar 消失、进程已退出。
let deadline = Instant::now() + Duration::from_secs(30);
while sidecar_path.exists() {
assert!(Instant::now() < deadline, "server 应在有界时间内退出");
std::thread::sleep(Duration::from_millis(50));
}
wait_pid_dead(id.pid, Duration::from_secs(30));
}