fix: refine system restart handling

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-04 11:37:48 +08:00
co-authored by Codex
parent fe32f0c143
commit ee7f343879
8 changed files with 223 additions and 15 deletions
+2 -1
View File
@@ -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 模式会先安排新进程延迟启动,再关闭当前进程。
## 项目结构
+27 -1
View File
@@ -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<broadcast::Sender<SystemUpdateEvent>> = OnceLock::new();
@@ -314,6 +317,9 @@ pub(crate) async fn restart(_auth: AdminAuth) -> Result<impl IntoResponse, Admin
));
}
let config = SystemUpdateConfig::from_env();
let message = schedule_restart(&config)?;
tokio::spawn(async {
tokio::time::sleep(Duration::from_millis(500)).await;
request_shutdown();
@@ -322,12 +328,32 @@ pub(crate) async fn restart(_auth: AdminAuth) -> Result<impl IntoResponse, Admin
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(serde_json::json!({
"message": "已安排重启",
"message": message,
"operationId": operation_id("restart")
})),
))
}
fn schedule_restart(config: &SystemUpdateConfig) -> 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");
+66 -7
View File
@@ -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<String> {
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<String> {
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<String> {
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<std::ffi::OsString>) {
if let Some(value) = value {
std::env::set_var(key, value);
} else {
std::env::remove_var(key);
}
}
}
+17 -1
View File
@@ -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<dyn Error + Send + Sync>> {
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<Option<LogGuard>, 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::<u64>().ok())
.filter(|value| *value > 0)
else {
return;
};
tokio::time::sleep(Duration::from_millis(delay_ms)).await;
}
+81
View File
@@ -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;
+2 -1
View File
@@ -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`。
+20 -2
View File
@@ -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(() => {
@@ -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) => {