diff --git a/Cargo.lock b/Cargo.lock index ac258107..eaafea20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1160,7 +1160,7 @@ dependencies = [ [[package]] name = "oxide_builtins" -version = "0.8.38" +version = "0.8.39" dependencies = [ "chrono", "fast-float", @@ -1185,7 +1185,7 @@ dependencies = [ [[package]] name = "oxide_bytecode" -version = "0.8.38" +version = "0.8.39" dependencies = [ "num-bigint", "oxide_log", @@ -1193,7 +1193,7 @@ dependencies = [ [[package]] name = "oxide_cfg" -version = "0.8.38" +version = "0.8.39" dependencies = [ "oxide_bytecode", "oxide_emit", @@ -1204,7 +1204,7 @@ dependencies = [ [[package]] name = "oxide_cli" -version = "0.8.38" +version = "0.8.39" dependencies = [ "ansi_term", "bumpalo", @@ -1225,7 +1225,7 @@ dependencies = [ [[package]] name = "oxide_code_cache" -version = "0.8.38" +version = "0.8.39" dependencies = [ "lru", "oxide_bytecode", @@ -1234,7 +1234,7 @@ dependencies = [ [[package]] name = "oxide_compiler" -version = "0.8.38" +version = "0.8.39" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1253,7 +1253,7 @@ dependencies = [ [[package]] name = "oxide_dce" -version = "0.8.38" +version = "0.8.39" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1267,7 +1267,7 @@ dependencies = [ [[package]] name = "oxide_emit" -version = "0.8.38" +version = "0.8.39" dependencies = [ "num-bigint", "oxc_allocator", @@ -1282,7 +1282,7 @@ dependencies = [ [[package]] name = "oxide_ir" -version = "0.8.38" +version = "0.8.39" dependencies = [ "oxide_bytecode", "oxide_log", @@ -1291,7 +1291,7 @@ dependencies = [ [[package]] name = "oxide_kernel" -version = "0.8.38" +version = "0.8.39" dependencies = [ "bumpalo", "dashmap", @@ -1306,7 +1306,7 @@ dependencies = [ [[package]] name = "oxide_liveness" -version = "0.8.38" +version = "0.8.39" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1318,7 +1318,7 @@ dependencies = [ [[package]] name = "oxide_log" -version = "0.8.38" +version = "0.8.39" dependencies = [ "tracing", "tracing-appender", @@ -1327,7 +1327,7 @@ dependencies = [ [[package]] name = "oxide_parser" -version = "0.8.38" +version = "0.8.39" dependencies = [ "oxc_allocator", "oxc_ast", @@ -1342,7 +1342,7 @@ dependencies = [ [[package]] name = "oxide_regalloc" -version = "0.8.38" +version = "0.8.39" dependencies = [ "oxide_bytecode", "oxide_cfg", @@ -1358,7 +1358,7 @@ dependencies = [ [[package]] name = "oxide_runtime_api" -version = "0.8.38" +version = "0.8.39" dependencies = [ "num-bigint", "num-traits", @@ -1370,7 +1370,7 @@ dependencies = [ [[package]] name = "oxide_test262" -version = "0.8.38" +version = "0.8.39" dependencies = [ "oxide_bytecode", "oxide_compiler", @@ -1387,7 +1387,7 @@ dependencies = [ [[package]] name = "oxide_types" -version = "0.8.38" +version = "0.8.39" dependencies = [ "bumpalo", "hashbrown 0.14.5", @@ -1399,7 +1399,7 @@ dependencies = [ [[package]] name = "oxide_vm" -version = "0.8.38" +version = "0.8.39" dependencies = [ "bumpalo", "chrono", diff --git a/Cargo.toml b/Cargo.toml index d1341978..23c54b25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ members = [ ] [workspace.package] -version = "0.8.38" +version = "0.8.39" 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 e326e2f4..154d0bef 100644 --- a/crates/oxide_cli/src/main.rs +++ b/crates/oxide_cli/src/main.rs @@ -110,7 +110,7 @@ enum Commands { /// server 子命令:持久 server 进程管理与 --rm 独立模式。 #[derive(Subcommand)] enum ServerCommands { - /// 启动 server。持久形态前台常驻(阻塞至关闭请求、信号或 yield 触发退出); + /// 启动 server。持久形态为守护形态(spawn 脱离的后台进程,就绪探测通过后返回); /// --rm 为独立形态(进程唯一 socket、不注册 sidecar、空闲超时或断开即退出)。 Start { /// 独立模式:进程唯一 socket 路径,不注册 sidecar,空闲超时或断开即自动退出。 @@ -122,6 +122,10 @@ enum ServerCommands { /// 常驻 worker 线程数;缺省取宿主核数。 #[arg(long)] workers: Option, + /// 前台形态:当前进程直接运行持久 server(守护形态 spawn 的子进程用, + /// 不向用户暴露)。 + #[arg(long, hide = true)] + foreground: bool, }, /// 向持久 server 发送关闭请求。 Stop, @@ -212,8 +216,10 @@ fn main() -> ExitCode { /// 其余两臂为 not_implemented 占位。 /// /// # 步骤 -/// 1. Start:rm 为假调持久 server 入口(well-known 路径),rm 为真调独立 -/// 模式入口(进程唯一路径);`--workers` 为 Some 时覆盖 worker 数。 +/// 1. Start:rm 为真调独立模式入口(进程唯一路径);foreground 为真当前进程 +/// 直接运行持久 server(守护形态 spawn 的子进程用);否则 spawn 脱离的 +/// server 子进程并就绪探测通过后返回(守护形态,well-known 路径); +/// `--workers` 为 Some 时覆盖 worker 数。 /// 2. Cleanup:调 well-known 路径清理入口,四态结果映射退出码(无残留与 /// 已清理退 0,拒绝与杀进程失败退 1)并打印结果。 /// 3. 五个控制臂(version/status/health/info/stop):发对应控制请求, @@ -227,12 +233,13 @@ fn main() -> ExitCode { /// - 全局旗标 -v / -q / --profile 在 server 臂为空操作。 /// /// # 副作用 -/// - start 阻塞当前进程至 server 退出(持久形态为前台常驻)。 +/// - start 持久形态 spawn 脱离进程,就绪探测通过后返回(server 由 init +/// 收领继续运行)。 /// - cleanup 可能向 sidecar 记录的进程发 SIGTERM 并删除残留文件。 /// - 控制臂向 server 建立并关闭一条 Unix socket 连接。 fn server_command(command: ServerCommands) -> ExitCode { match command { - ServerCommands::Start { rm, idle_timeout, workers } => { + ServerCommands::Start { rm, idle_timeout, workers, foreground } => { if rm { let mut config = RmServerConfig { idle_timeout: Duration::from_secs(idle_timeout), @@ -242,12 +249,14 @@ fn server_command(command: ServerCommands) -> ExitCode { config.worker_count = n as usize; } start_result(run_server_rm(&config)) - } else { + } else if foreground { let mut config = ServerConfig::well_known(); if let Some(n) = workers { config.worker_count = n as usize; } start_result(run_server(&config)) + } else { + start_daemon(workers) } } ServerCommands::Cleanup => match well_known_cleanup() { @@ -322,6 +331,95 @@ fn control_arm(request: &ServerRequest, render: fn(&ServerResponse) -> Option) -> ExitCode { + // spawn:脱离子进程,标准流全空,不等待。 + if let Err(err) = spawn_detached_server(workers) { + eprintln!("{}", Red.paint(format!("spawn server 进程失败:{err}"))); + return ExitCode::FAILURE; + } + + // 就绪探测:单循环全覆盖「sidecar 出现 + 健康请求通过」。 + if let Err(msg) = wait_server_ready(Duration::from_secs(10)) { + eprintln!("{}", Red.paint(format!("{msg}(可用 `oxide server cleanup` 人工检查)"))); + return ExitCode::FAILURE; + } + + println!("server 已启动"); + ExitCode::SUCCESS +} + +/// spawn 脱离的前台 server 子进程(`server start --foreground`):标准流全空,不等待。 +/// +/// # 步骤 +/// 1. 取当前可执行文件路径。 +/// 2. 构造命令 `server start --foreground` 加可选 `--workers N`。 +/// 3. 三个标准流置空并 spawn。 +/// +/// # 边界与前提 +/// - 必须带 `--foreground`:守护形态 spawn 的是前台 server 入口,不带该旗标会 +/// 再次进入守护形态形成递归 spawn。 +/// +/// # 副作用 +/// - 创建脱离的子进程,调用方退出后由 init 收领继续运行。 +fn spawn_detached_server(workers: Option) -> std::io::Result { + let exe = std::env::current_exe()?; + let mut cmd = Command::new(exe); + cmd.args(["server", "start", "--foreground"]); + if let Some(n) = workers { + cmd.args(["--workers", &n.to_string()]); + } + cmd.stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() +} + +/// 就绪探测:轮询健康请求直至得 healthy。 +/// +/// # 步骤 +/// 1. 循环发健康请求,得 healthy 即返回成功。 +/// 2. 超过截止返回含超时秒数的消息。 +/// +/// # 边界与前提 +/// - 控制客户端发请求前先读 sidecar 取 socket 路径,sidecar 缺失时自然走 +/// 连接失败分支,「sidecar 出现」与「健康请求通过」一个循环全覆盖 +/// (socket 文件出现蕴含监听器已绑定,健康请求通过蕴含 accept 循环在运行)。 +/// +/// # 副作用 +/// - 每轮建立并关闭一条 Unix socket 连接。 +fn wait_server_ready(deadline: Duration) -> Result<(), String> { + let end = Instant::now() + deadline; + loop { + let ready = + matches!(send_control_request(&ServerRequest::Health), Ok(ServerResponse::Health { healthy: true })); + if ready { + return Ok(()); + } + if Instant::now() >= end { + return Err(format!("server 未在 {} 秒内就绪", deadline.as_secs())); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + /// restart 子命令:向存活 server 发关闭请求,等其退出后拉起新 server 进程。 /// /// # 步骤 @@ -360,37 +458,15 @@ fn restart_command() -> ExitCode { } // 拉起新 server:脱离进程,标准流全空,不等待。 - let exe = match std::env::current_exe() { - Ok(path) => path, - Err(err) => { - eprintln!("{}", Red.paint(format!("获取当前可执行文件路径失败:{err}"))); - return ExitCode::FAILURE; - } - }; - if let Err(err) = Command::new(exe) - .args(["server", "start"]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - { + if let Err(err) = spawn_detached_server(None) { eprintln!("{}", Red.paint(format!("拉起新 server 失败:{err}"))); return ExitCode::FAILURE; } // 就绪探测:健康请求通过即新 server 的监听与 accept 循环在运行。 - let deadline = Instant::now() + Duration::from_secs(10); - loop { - let ready = - matches!(send_control_request(&ServerRequest::Health), Ok(ServerResponse::Health { healthy: true })); - if ready { - break; - } - if Instant::now() >= deadline { - eprintln!("{}", Red.paint("新 server 未在 10 秒内就绪")); - return ExitCode::FAILURE; - } - std::thread::sleep(Duration::from_millis(100)); + if let Err(msg) = wait_server_ready(Duration::from_secs(10)) { + eprintln!("{}", Red.paint(format!("新 {msg}"))); + return ExitCode::FAILURE; } println!("已重启"); diff --git a/crates/oxide_cli/tests/server_cli.rs b/crates/oxide_cli/tests/server_cli.rs index 9563bf4f..a4cc659c 100644 --- a/crates/oxide_cli/tests/server_cli.rs +++ b/crates/oxide_cli/tests/server_cli.rs @@ -99,8 +99,9 @@ fn server_cleanup_exit_0() { assert!(!sidecar::well_known_sidecar_path().exists(), "sidecar 文件应不存在"); } -/// start 端到端:起真实子进程,well-known 路径连接,版本帧断言构建期版本, -/// 关闭帧后子进程退 0,socket 与 sidecar 文件被删。 +/// start 端到端:CLI 即刻返回(server 是脱离的孙进程),断言退 0 且打印 +/// 「已启动」,well-known 路径连接,版本帧断言构建期版本,关闭帧后 server +/// 进程退出,socket 与 sidecar 文件被删。 #[test] fn server_start_e2e() { let _guard = WELL_KNOWN_LOCK.lock().expect("全局路径锁不应中毒"); @@ -110,18 +111,15 @@ fn server_start_e2e() { // 探活先行:存活 server 占用全局路径时 panic,不抢占。 assert!(!sidecar::is_server_alive(&socket), "存活 server 占用 well-known 路径,测试不抢占"); - // worker 数压到 2:避免按宿主核数预热的成本。 - let mut child = Command::new(env!("CARGO_BIN_EXE_oxide")) - .args(["server", "start", "--workers", "2"]) - .spawn() - .expect("oxide server start 应可启动"); + // 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}"); - // 轮询 socket 文件出现(10 秒截止)判就绪。 - let deadline = Instant::now() + Duration::from_secs(10); - while !socket.exists() { - assert!(Instant::now() < deadline, "socket 文件未出现"); - std::thread::sleep(Duration::from_millis(10)); - } + // 读 sidecar 取 server 进程号。 + let id = sidecar::read_identity(&sidecar_path).expect("读 sidecar 应成功"); // 版本请求帧:版本应等于构建期版本。 let mut stream = UnixStream::connect(&socket).expect("连接 server 应成功"); @@ -138,24 +136,54 @@ fn server_start_e2e() { assert!(matches!(response, ServerResponse::Shutdown), "应得关闭确认帧:{response:?}"); drop(stream); - // 等子进程以退出码 0 退出(30 秒截止)。 + // 轮询 sidecar 文件消失(30 秒截止、50 毫秒间隔)判 server 进程已退出。 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}"), - } + 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 文件应被删除"); + assert!(!sidecar::pid_alive(id.pid), "server 进程应已退出"); +} + +/// 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`(发关闭、等退出、