mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix: refine system restart handling
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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 模式会先安排新进程延迟启动,再关闭当前进程。
|
||||
|
||||
## 项目结构
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user