diff --git a/README.md b/README.md index 0ec9d7c5e..0ac274b74 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,7 @@ CPR_CONFIG_FILE=.runtime/config.yaml cargo run --manifest-path backend/Cargo.tom ```text .runtime/config.yaml # 本地或 Docker 启动配置,按实际运行环境写路径 .runtime/data/codex-proxy-rs.sqlite # SQLite 数据库 +.runtime/data/installation_id # 上游 installation id .runtime/data/update-state.json # 在线更新状态 .runtime/data/update.lock # 在线更新锁 .runtime/logs/ # 日志 @@ -177,7 +178,7 @@ release/publish 0.1.5 - 下载匹配当前平台的归档和 `checksums.txt`。 - 校验 checksum。 - 替换二进制和 `web/dist`。 -- Docker 模式下依赖 `restart: unless-stopped` 和自重启重新拉起服务。 +- 重启时,Docker 模式依赖 `restart: unless-stopped` 拉起新容器进程;非 Docker 模式会先安排新进程延迟启动,再关闭当前进程。 ## 项目结构 diff --git a/backend/src/admin/system/updater/mod.rs b/backend/src/admin/system/updater/mod.rs index efd37b167..0b5ee22b8 100644 --- a/backend/src/admin/system/updater/mod.rs +++ b/backend/src/admin/system/updater/mod.rs @@ -4,6 +4,7 @@ use std::{ convert::Infallible, env, fmt, fs, io, path::{Path, PathBuf}, + process::Command, sync::OnceLock, time::Duration, }; @@ -49,6 +50,8 @@ const APP_BINARY_NAME: &str = "codex-proxy-rs"; const DEFAULT_WEB_DIST_DIR: &str = "/app/web/dist"; const GITHUB_API_BASE: &str = "https://api.github.com/repos"; const MAX_DOWNLOAD_SIZE: u64 = 500 * 1024 * 1024; +const RESTART_DELAY_ENV: &str = "CPR_RESTART_DELAY_MS"; +const REPLACEMENT_START_DELAY_MS: &str = "1200"; static SYSTEM_OPERATION_LOCK: Mutex<()> = Mutex::const_new(()); static UPDATE_EVENT_SENDER: OnceLock> = OnceLock::new(); @@ -314,6 +317,9 @@ pub(crate) async fn restart(_auth: AdminAuth) -> Result Result Result<&'static str, AdminError> { + if config.deployment_mode == "docker" { + return Ok("已安排重启"); + } + + spawn_replacement_process(config)?; + Ok("已安排自重启") +} + +fn spawn_replacement_process(config: &SystemUpdateConfig) -> Result<(), AdminError> { + let executable_path = config.executable_path()?; + let mut command = Command::new(executable_path); + command + .args(env::args_os().skip(1)) + .env(RESTART_DELAY_ENV, REPLACEMENT_START_DELAY_MS); + command.spawn().map(|_| ()).map_err(internal_error_with( + "Failed to schedule replacement process", + )) +} + impl SystemUpdateConfig { fn from_env() -> Self { let update_repository = env_string("CPR_UPDATE_REPOSITORY"); diff --git a/backend/src/infra/paths.rs b/backend/src/infra/paths.rs index 4b2e94a4c..d9817ded6 100644 --- a/backend/src/infra/paths.rs +++ b/backend/src/infra/paths.rs @@ -10,10 +10,7 @@ use uuid::Uuid; const INSTALLATION_ID_FILE_NAME: &str = "installation_id"; fn data_dir() -> PathBuf { - dirs::data_local_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("codex-proxy-rs") - .join("data") + dirs::data_local_dir().unwrap_or_else(|| PathBuf::from(".runtime/data")) } /// 确保本地数据目录存在。 @@ -33,7 +30,7 @@ fn data_installation_id_path(data_dir: &Path) -> PathBuf { /// 按读取优先级读取或生成 installation ID。 /// -/// 顺序为真实 Codex Desktop 文件、本应用数据目录文件、生成并写入本应用数据目录。 +/// 顺序为本应用数据目录文件、真实 Codex Desktop 文件、生成并写入本应用数据目录。 /// 当 `data_dir` 为 `None` 且没有可读文件时,返回一个不会持久化的新 UUID。 pub fn load_or_create_installation_id(data_dir: Option<&Path>) -> io::Result { let codex_path = codex_desktop_installation_id_path(); @@ -45,17 +42,22 @@ fn load_or_create_installation_id_from_paths( codex_desktop_path: Option<&Path>, data_path: Option<&Path>, ) -> io::Result { - if let Some(path) = codex_desktop_path { + if let Some(path) = data_path { if let Some(id) = read_installation_id(path)? { return Ok(id); } } - if let Some(path) = data_path { + if let Some(path) = codex_desktop_path { if let Some(id) = read_installation_id(path)? { + if let Some(data_path) = data_path { + persist_installation_id(data_path, &id)?; + } return Ok(id); } + } + if let Some(path) = data_path { let generated = generate_installation_id(); persist_installation_id(path, &generated)?; return Ok(generated); @@ -88,3 +90,60 @@ fn parse_installation_id(raw: &str) -> Option { fn generate_installation_id() -> String { Uuid::new_v4().to_string() } + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use super::*; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn data_dir_should_use_xdg_data_home_directly() { + let _guard = ENV_LOCK.lock().unwrap(); + let dir = tempfile::tempdir().unwrap(); + let previous = std::env::var_os("XDG_DATA_HOME"); + std::env::set_var("XDG_DATA_HOME", dir.path()); + + let resolved = data_dir(); + + restore_env("XDG_DATA_HOME", previous); + assert_eq!(resolved, dir.path()); + } + + #[test] + fn load_or_create_installation_id_should_persist_under_data_dir_root() { + let dir = tempfile::tempdir().unwrap(); + let data_path = dir.path().join(INSTALLATION_ID_FILE_NAME); + + let installation_id = load_or_create_installation_id_from_paths(None, Some(&data_path)) + .expect("installation id should be generated"); + + assert_eq!(fs::read_to_string(data_path).unwrap(), installation_id); + } + + #[test] + fn load_or_create_installation_id_should_seed_data_dir_from_codex_desktop_id() { + let dir = tempfile::tempdir().unwrap(); + let codex_path = dir.path().join(".codex").join(INSTALLATION_ID_FILE_NAME); + let data_path = dir.path().join("data").join(INSTALLATION_ID_FILE_NAME); + let desktop_id = "018f8f6b-1d7b-7b7c-b9c8-8c9f4c6d0e1a"; + persist_installation_id(&codex_path, desktop_id).unwrap(); + + let installation_id = + load_or_create_installation_id_from_paths(Some(&codex_path), Some(&data_path)) + .expect("installation id should be seeded"); + + assert_eq!(installation_id, desktop_id); + assert_eq!(fs::read_to_string(data_path).unwrap(), desktop_id); + } + + fn restore_env(key: &str, value: Option) { + if let Some(value) = value { + std::env::set_var(key, value); + } else { + std::env::remove_var(key); + } + } +} diff --git a/backend/src/runtime/bootstrap.rs b/backend/src/runtime/bootstrap.rs index 0fe56736f..eda54c8ab 100644 --- a/backend/src/runtime/bootstrap.rs +++ b/backend/src/runtime/bootstrap.rs @@ -1,4 +1,4 @@ -use std::error::Error; +use std::{env, error::Error, time::Duration}; use axum::Router; use chrono::Utc; @@ -15,7 +15,11 @@ use crate::runtime::state::{AppState, RuntimeConfig}; use crate::runtime::tasks::coordinator::TaskCoordinator; use crate::upstream::fingerprint::{Fingerprint, FingerprintRepository}; +const RESTART_DELAY_ENV: &str = "CPR_RESTART_DELAY_MS"; + pub async fn run() -> Result<(), Box> { + wait_for_scheduled_restart().await; + let config = AppConfig::load()?; let host = config.server.host.clone(); let port = config.server.port; @@ -116,3 +120,15 @@ fn init_logging(config: &AppConfig) -> Result, crate::infra::lo let rotation = RotationConfig::new(&config.logging.directory, config.logging.retention_days); Ok(Some(init_tracing(&rotation)?)) } + +async fn wait_for_scheduled_restart() { + let Some(delay_ms) = env::var(RESTART_DELAY_ENV) + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + else { + return; + }; + + tokio::time::sleep(Duration::from_millis(delay_ms)).await; +} diff --git a/backend/tests/admin/system/mod.rs b/backend/tests/admin/system/mod.rs index 762f22b81..0069cfd33 100644 --- a/backend/tests/admin/system/mod.rs +++ b/backend/tests/admin/system/mod.rs @@ -198,6 +198,87 @@ async fn restart_should_request_graceful_shutdown() { ); } +#[tokio::test] +async fn restart_should_spawn_replacement_before_shutdown_outside_docker() { + let _guard = SYSTEM_ENV_LOCK.lock().await; + let replacement = Path::new("/bin/true"); + if !replacement.exists() { + return; + } + set_system_update_env( + "zyycn/codex-proxy-rs-binary-restart-route", + "http://127.0.0.1:9", + ); + std::env::set_var("CPR_DEPLOYMENT_MODE", "binary"); + std::env::set_var("CPR_ENABLE_SELF_RESTART", "true"); + std::env::set_var("CPR_UPDATE_EXE_PATH", replacement); + let (app, _dir) = admin_system_test_app("system-binary-restart.sqlite").await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/system/restart") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_system_binary_restart") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json(response).await; + let shutdown = tokio::time::timeout( + Duration::from_secs(2), + codex_proxy_rs::runtime::shutdown::shutdown_signal(), + ) + .await; + + assert!(body["data"]["message"] == "已安排自重启" && shutdown.is_ok()); +} + +#[tokio::test] +async fn restart_should_not_shutdown_when_replacement_spawn_fails() { + let _guard = SYSTEM_ENV_LOCK.lock().await; + set_system_update_env( + "zyycn/codex-proxy-rs-restart-spawn-failure-route", + "http://127.0.0.1:9", + ); + std::env::set_var("CPR_DEPLOYMENT_MODE", "binary"); + std::env::set_var("CPR_ENABLE_SELF_RESTART", "true"); + std::env::set_var( + "CPR_UPDATE_EXE_PATH", + "/tmp/codex-proxy-rs-missing-restart-binary", + ); + let (app, _dir) = admin_system_test_app("system-restart-spawn-failure.sqlite").await; + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/system/restart") + .header("cookie", "cpr_admin_session=session_1") + .header("x-request-id", "req_system_restart_spawn_failure") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response_json(response).await; + let shutdown = tokio::time::timeout( + Duration::from_millis(100), + codex_proxy_rs::runtime::shutdown::shutdown_signal(), + ) + .await; + let message = body["message"].as_str().unwrap_or_default(); + + assert!( + status == StatusCode::INTERNAL_SERVER_ERROR + && message.starts_with("Failed to schedule replacement process:") + && shutdown.is_err() + ); +} + #[tokio::test] async fn update_should_replace_local_release_files_with_latest_asset() { let _guard = SYSTEM_ENV_LOCK.lock().await; diff --git a/docs/architecture.md b/docs/architecture.md index f2869a140..a7c6c56fc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -85,6 +85,7 @@ flowchart LR ```text .runtime/config.yaml .runtime/data/codex-proxy-rs.sqlite +.runtime/data/installation_id .runtime/data/update-state.json .runtime/data/update.lock .runtime/logs/ @@ -196,7 +197,7 @@ GHCR 镜像发布 `linux/amd64` 和 `linux/arm64` 多平台 manifest。 6. 替换当前二进制和前端静态资源。 7. 写入 `.runtime/data/update-state.json`。 8. 通过 SSE 推送中文更新日志。 -9. 管理端触发重启;Docker 模式依靠 `restart: unless-stopped` 拉起新进程。 +9. 管理端触发重启;Docker 模式依靠 `restart: unless-stopped` 拉起新容器进程,非 Docker 模式先安排新进程延迟启动,再关闭当前进程。 替换逻辑必须支持跨文件系统 fallback,因为容器环境中 `rename` 可能遇到 `Invalid cross-device link`。 diff --git a/frontend/src/composables/useSystemUpdate.ts b/frontend/src/composables/useSystemUpdate.ts index 4bfe4205b..e38411719 100644 --- a/frontend/src/composables/useSystemUpdate.ts +++ b/frontend/src/composables/useSystemUpdate.ts @@ -206,7 +206,18 @@ async function waitForServiceAndReload() { } } - window.location.reload() + updateError.value = '服务重启后暂不可用,请检查进程是否已被自重启或外部守护进程拉起' + restarting.value = false + restartCountdown.value = 0 +} + +function apiErrorStatus(error: unknown) { + if (typeof error !== 'object' || error === null || !('status' in error)) { + return 0 + } + + const status = (error as { status?: unknown }).status + return typeof status === 'number' ? status : 0 } async function restartNow() { @@ -214,12 +225,19 @@ async function restartNow() { restarting.value = true restartCountdown.value = 8 + updateError.value = '' clearRestartTimer() try { await restartSystem() - } catch { + } catch (error) { // The connection can drop immediately after the restart request is accepted. + if (apiErrorStatus(error) > 0) { + restarting.value = false + restartCountdown.value = 0 + updateError.value = error instanceof Error ? error.message : '重启失败' + throw error + } } restartTimer = window.setInterval(() => { diff --git a/frontend/src/layout/components/SystemUpdateModal.vue b/frontend/src/layout/components/SystemUpdateModal.vue index f7c7e0db3..79c21012b 100644 --- a/frontend/src/layout/components/SystemUpdateModal.vue +++ b/frontend/src/layout/components/SystemUpdateModal.vue @@ -180,8 +180,14 @@ async function handleUpdate() { } async function handleRestart() { - toast.success('正在重启服务') - await restartNow() + try { + await restartNow() + if (restarting.value) { + toast.success('正在重启服务') + } + } catch (error: any) { + toast.error(error.message || '重启失败') + } } watch(open, (visible) => {