diff --git a/backend/crates/gateway-host/src/system_update/mod.rs b/backend/crates/gateway-host/src/system_update/mod.rs index 3502d8c0d..e83530a10 100644 --- a/backend/crates/gateway-host/src/system_update/mod.rs +++ b/backend/crates/gateway-host/src/system_update/mod.rs @@ -241,9 +241,13 @@ pub struct ProcessSystemOperations { impl ProcessSystemOperations { #[must_use] - pub fn new(cancellation: CancellationToken, config: SystemUpdateConfig) -> Self { - // 组合根在接收请求前创建服务,记录本次启动的发行文件,后续替换不能改写这份事实。 - let running_files = Arc::new(ReleaseFiles::installed(&config)); + pub fn new(cancellation: CancellationToken, mut config: SystemUpdateConfig) -> Self { + // 组合根在接收请求前固定安装路径和发行文件;旧程序重命名后, + // current_exe 可能指向备份,后续更新、回滚和重启不能再反查运行文件。 + let running_files = Arc::new(config.executable_path().and_then(|executable| { + config.executable_path = Some(executable); + ReleaseFiles::installed(&config) + })); Self { cancellation, events: Arc::new(UpdateEvents::default()), diff --git a/backend/crates/gateway-host/tests/system_update/mod.rs b/backend/crates/gateway-host/tests/system_update/mod.rs index b5ff40259..ef6a62b9b 100644 --- a/backend/crates/gateway-host/tests/system_update/mod.rs +++ b/backend/crates/gateway-host/tests/system_update/mod.rs @@ -206,6 +206,73 @@ async fn restart_should_spawn_replacement_before_shutdown_outside_docker() { .expect("replacement executed before fixture cleanup"); } +#[cfg(target_os = "linux")] +#[tokio::test] +async fn restart_should_use_startup_path_after_running_executable_is_renamed() { + const CHILD_ENV: &str = "CPR_TEST_RESTART_RENAMED_EXECUTABLE_CHILD"; + let executable = std::env::current_exe().expect("test executable"); + if std::env::var_os(CHILD_ENV).is_none() { + // 只重命名隔离副本,避免修改 Cargo 的测试程序或影响并行测试。 + let directory = tempfile::tempdir().expect("isolated test directory"); + let child = directory.path().join("restart-test"); + fs::copy(&executable, &child).expect("copy test executable"); + let output = std::process::Command::new(child) + .args([ + "--exact", + "system_update::restart_should_use_startup_path_after_running_executable_is_renamed", + ]) + .env(CHILD_ENV, "1") + .env("CPR_RESTART_DELAY_MS", "1200") + .output() + .expect("isolated restart test"); + assert!( + output.status.success(), + "stdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + if executable.file_name() == Some(std::ffi::OsStr::new("codex-proxy-rs.backup")) { + // 误启动旧程序时留下可观察结果并退出,避免旧副本再次发起重启。 + fs::write( + executable + .parent() + .expect("installation directory") + .join("replacement-version"), + "old", + ) + .expect("old replacement marker"); + return; + } + + let fixture = Fixture::new(); + fs::rename(executable, fixture.executable()).expect("move isolated running executable"); + let mut config = fixture.config("http://127.0.0.1:1/repos"); + config.executable_path = None; + config.self_restart_enabled = true; + let service = ProcessSystemOperations::new(CancellationToken::new(), config); + + let backup = fixture.root.path().join("codex-proxy-rs.backup"); + fs::rename(fixture.executable(), &backup).expect("back up running executable"); + fixture.write_executable("#!/bin/sh\nprintf new > \"${0%/*}/replacement-version\"\n"); + assert_eq!(std::env::current_exe().expect("renamed executable"), backup); + + service.restart().await.expect("restart accepted"); + let marker = fixture.root.path().join("replacement-version"); + tokio::time::timeout(Duration::from_secs(5), async { + while !marker.is_file() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("replacement executed"); + assert_eq!( + fs::read_to_string(marker).expect("replacement version"), + "new" + ); +} + #[tokio::test] async fn restart_should_conflict_while_another_system_operation_is_running() { let fixture = Fixture::new(); diff --git a/deploy/README.md b/deploy/README.md index 7358b5091..2a72ec416 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -526,6 +526,23 @@ alpha、beta、rc、exp 在 GitHub 标记为 Pre-release,不覆盖 GitHub Late 回滚入口只接受与记录指纹一致的完整备份;旧格式记录没有指纹,或备份被替换、丢失时,不再提供在线回滚, 但不会删除备份文件。此时需要按人工部署流程恢复已经核实的发行包。 +直接运行二进制时,若未设置 `CPR_ENABLE_SELF_RESTART=true`,管理端自重启默认关闭。 +在现有 `deploy/config.yaml` 的 `host` 下合并以下配置: + +```yaml +host: + system_update: + deployment_mode: binary + self_restart_enabled: true + executable_path: '/opt/codex-proxy-rs/codex-proxy-rs' +``` + +`executable_path` 为可选项:未指定时,服务在启动时解析并固定当前程序路径; +显式配置时,将示例改为实际程序的绝对路径。`self_restart_enabled` 控制管理端的「立即重启」操作; +修改配置后需手动重启当前服务一次才会生效。 +以上三个字段也可分别通过 `CPR_DEPLOYMENT_MODE`、`CPR_ENABLE_SELF_RESTART` 和 `CPR_UPDATE_EXE_PATH` 设置, +YAML 中显式配置的值优先于对应环境变量。 + Compose 提供以下在线更新运行参数: - `CPR_UPDATE_REPOSITORY`:只接受 `owner/repository`;默认 `zyycn/codex-proxy-rs`。 diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index d6899e2f6..3e1c82ecb 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -29,6 +29,12 @@ host: directory: '../.runtime/logs' retention_days: 7 max_file_size_mb: 20 + # 二进制部署需要管理端自重启时,取消以下注释;可选路径改为实际程序绝对路径。 + # Compose 已通过环境变量开启,保持本段注释即可;详情见 deploy/README.md。 + # system_update: + # deployment_mode: 'binary' + # self_restart_enabled: true + # executable_path: '/opt/codex-proxy-rs/codex-proxy-rs' # 可选:覆盖启动时解析的程序路径。 drain_timeout_seconds: 30 worker_shutdown_timeout_seconds: 30