mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 06:56:44 +08:00
@@ -24,6 +24,7 @@ const DAEMON_PID_FILE: &str = "bat.pid";
|
||||
const DAEMON_STATUS_FILE: &str = "bat-status.json";
|
||||
const DAEMON_LOG_FILE: &str = "bat-daemon.log";
|
||||
const DAEMON_SOCKET_FILE: &str = "bat.sock";
|
||||
const DAEMON_CONTROL_LOCK_FILE: &str = "bat-control.lock";
|
||||
const DAEMON_STATUS_VERSION: u32 = 1;
|
||||
const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
|
||||
const BEIJING_UTC_OFFSET_SECONDS: u64 = 8 * 60 * 60;
|
||||
@@ -82,10 +83,15 @@ fn run() -> anyhow::Result<i32> {
|
||||
match options.command {
|
||||
CliCommand::Run => {
|
||||
if options.daemon {
|
||||
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||
run_daemon_start(options)?;
|
||||
} else if options.watch {
|
||||
if !options.daemon_child {
|
||||
assert_no_live_daemon_output_conflict(&options, "watch")?;
|
||||
}
|
||||
run_watch(options)?;
|
||||
} else {
|
||||
assert_no_live_daemon_output_conflict(&options, "run")?;
|
||||
let mut logger = ProgressLogger::new(options.progress);
|
||||
let report =
|
||||
OfficialUpdateService::new().run_with_progress(&options.config, |event| {
|
||||
@@ -98,18 +104,22 @@ fn run() -> anyhow::Result<i32> {
|
||||
Ok(0)
|
||||
}
|
||||
CliCommand::Status => {
|
||||
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||
print_daemon_status(&options.state_dir, options.output_format)?;
|
||||
Ok(0)
|
||||
}
|
||||
CliCommand::Stop => {
|
||||
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||
stop_daemon(&options.state_dir, options.output_format)?;
|
||||
Ok(0)
|
||||
}
|
||||
CliCommand::Restart => {
|
||||
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||
run_daemon_restart(&options, "restart")?;
|
||||
Ok(0)
|
||||
}
|
||||
CliCommand::Reload => {
|
||||
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||
run_daemon_restart(&options, "reload")?;
|
||||
Ok(0)
|
||||
}
|
||||
@@ -126,6 +136,7 @@ fn run() -> anyhow::Result<i32> {
|
||||
Ok(0)
|
||||
}
|
||||
CliCommand::Logs => {
|
||||
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||
run_logs_command(&options)?;
|
||||
Ok(0)
|
||||
}
|
||||
@@ -557,6 +568,224 @@ impl Drop for DaemonRpcServer {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DaemonControlLock {
|
||||
path: PathBuf,
|
||||
pid: u32,
|
||||
}
|
||||
|
||||
impl DaemonControlLock {
|
||||
fn acquire(state_dir: &Path) -> anyhow::Result<Self> {
|
||||
fs::create_dir_all(state_dir)?;
|
||||
let path = daemon_control_lock_path(state_dir);
|
||||
let pid = std::process::id();
|
||||
for attempt in 0..=1 {
|
||||
match OpenOptions::new().write(true).create_new(true).open(&path) {
|
||||
Ok(mut file) => {
|
||||
file.write_all(pid.to_string().as_bytes())?;
|
||||
return Ok(Self { path, pid });
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
if attempt == 0 && remove_recoverable_pid_lock(&path)? {
|
||||
continue;
|
||||
}
|
||||
return Err(anyhow::anyhow!(
|
||||
"后台控制命令已被锁定 (locked):{};{};如确认没有 bat 控制命令正在运行,可执行 clean-stable 清理",
|
||||
path.display(),
|
||||
describe_pid_lock_owner(&path)?
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"获取后台控制锁失败 {}:{error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!("获取后台控制锁失败 {}", path.display()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DaemonControlLock {
|
||||
fn drop(&mut self) {
|
||||
let expected = self.pid.to_string();
|
||||
if fs::read_to_string(&self.path)
|
||||
.map(|contents| contents.trim() == expected)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum PidLockState {
|
||||
Missing,
|
||||
Active(u32),
|
||||
StalePid(u32),
|
||||
Corrupt,
|
||||
}
|
||||
|
||||
fn classify_pid_lock_file(path: &Path) -> anyhow::Result<PidLockState> {
|
||||
let contents = match fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(PidLockState::Missing);
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let Some(pid) = parse_pid_value(&contents) else {
|
||||
return Ok(PidLockState::Corrupt);
|
||||
};
|
||||
if process_exists(pid) {
|
||||
Ok(PidLockState::Active(pid))
|
||||
} else {
|
||||
Ok(PidLockState::StalePid(pid))
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_recoverable_pid_lock(path: &Path) -> anyhow::Result<bool> {
|
||||
match classify_pid_lock_file(path)? {
|
||||
PidLockState::Missing => Ok(false),
|
||||
PidLockState::Active(_) => Ok(false),
|
||||
PidLockState::StalePid(_) | PidLockState::Corrupt => {
|
||||
fs::remove_file(path)?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn describe_pid_lock_owner(path: &Path) -> anyhow::Result<String> {
|
||||
Ok(match classify_pid_lock_file(path)? {
|
||||
PidLockState::Missing => "锁文件已不存在".to_string(),
|
||||
PidLockState::Active(pid) => format!("owner_pid={pid} 仍在运行"),
|
||||
PidLockState::StalePid(pid) => format!("owner_pid={pid} 已失效"),
|
||||
PidLockState::Corrupt => "锁文件内容不是有效 PID".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_pid_value(contents: &str) -> Option<u32> {
|
||||
contents.trim().parse::<u32>().ok().filter(|pid| *pid > 0)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct DaemonResourceConflict {
|
||||
pid: Option<u32>,
|
||||
daemon_root: Option<PathBuf>,
|
||||
target_root: PathBuf,
|
||||
}
|
||||
|
||||
fn assert_no_live_daemon_output_conflict(
|
||||
options: &CliOptions,
|
||||
command_name: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
if options.daemon || options.daemon_child || options.config.dry_run {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(conflict) =
|
||||
live_daemon_resource_conflict(&options.state_dir, &options.config.output_root)?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let daemon_root = conflict
|
||||
.daemon_root
|
||||
.as_ref()
|
||||
.map(|path| path.display().to_string())
|
||||
.unwrap_or_else(|| "未知".to_string());
|
||||
let pid = conflict
|
||||
.pid
|
||||
.map(|pid| pid.to_string())
|
||||
.unwrap_or_else(|| "未知".to_string());
|
||||
Err(anyhow::anyhow!(
|
||||
"后台同步进程正在管理同一资源目录 (locked):command={command_name} pid={pid} daemon_output={} target_output={};请通过后台 refresh/reload 控制刷新,或先执行 stop 再手动写入资源",
|
||||
daemon_root,
|
||||
conflict.target_root.display()
|
||||
))
|
||||
}
|
||||
|
||||
fn live_daemon_resource_conflict(
|
||||
state_dir: &Path,
|
||||
output_root: &Path,
|
||||
) -> anyhow::Result<Option<DaemonResourceConflict>> {
|
||||
let pid_path = daemon_pid_path(state_dir);
|
||||
let status_path = daemon_status_path(state_dir);
|
||||
let pid_from_file = read_pid_file(&pid_path)?;
|
||||
let rpc_available = daemon_rpc_available(state_dir);
|
||||
let pid_file_live = pid_from_file.map(process_exists).unwrap_or(false);
|
||||
let status_file = match read_daemon_status_file(&status_path) {
|
||||
Ok(status_file) => status_file,
|
||||
Err(error) if pid_file_live || rpc_available => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"后台同步进程可能仍在运行,但状态文件不可解析 (locked):{}:{error};请先执行 status/stop 或 clean-stable 恢复状态目录",
|
||||
status_path.display()
|
||||
));
|
||||
}
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
let status_pid = status_file.as_ref().map(|status| status.pid);
|
||||
let pid = pid_from_file.or(status_pid);
|
||||
let live = pid_file_live || status_pid.map(process_exists).unwrap_or(false) || rpc_available;
|
||||
if !live {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let target_root = normalized_abs_path(output_root)?;
|
||||
let Some(daemon_root) = status_file
|
||||
.as_ref()
|
||||
.map(|status| status.resource_output_root.clone())
|
||||
else {
|
||||
return Ok(Some(DaemonResourceConflict {
|
||||
pid,
|
||||
daemon_root: None,
|
||||
target_root,
|
||||
}));
|
||||
};
|
||||
|
||||
let normalized_daemon_root = normalized_abs_path(&daemon_root)?;
|
||||
if normalized_daemon_root == target_root {
|
||||
Ok(Some(DaemonResourceConflict {
|
||||
pid,
|
||||
daemon_root: Some(normalized_daemon_root),
|
||||
target_root,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_abs_path(path: &Path) -> anyhow::Result<PathBuf> {
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
env::current_dir()?.join(path)
|
||||
};
|
||||
Ok(fs::canonicalize(&absolute).unwrap_or_else(|_| lexically_normalize_path(&absolute)))
|
||||
}
|
||||
|
||||
fn lexically_normalize_path(path: &Path) -> PathBuf {
|
||||
let mut normalized = PathBuf::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
std::path::Component::CurDir => {}
|
||||
std::path::Component::ParentDir => {
|
||||
if !normalized.pop() {
|
||||
normalized.push(component.as_os_str());
|
||||
}
|
||||
}
|
||||
_ => normalized.push(component.as_os_str()),
|
||||
}
|
||||
}
|
||||
if normalized.as_os_str().is_empty() {
|
||||
PathBuf::from(".")
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
|
||||
fn new_daemon_control() -> DaemonControl {
|
||||
Arc::new((Mutex::new(DaemonControlState::default()), Condvar::new()))
|
||||
}
|
||||
@@ -936,6 +1165,8 @@ struct DaemonStatusReport {
|
||||
status_path: PathBuf,
|
||||
socket_path: PathBuf,
|
||||
rpc_available: bool,
|
||||
stale_pid_file: bool,
|
||||
stale_socket: bool,
|
||||
log_path: Option<PathBuf>,
|
||||
started_unix_seconds: Option<u64>,
|
||||
updated_unix_seconds: Option<u64>,
|
||||
@@ -975,7 +1206,7 @@ fn run_daemon_start(options: CliOptions) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
fn start_daemon_with_options(options: &CliOptions) -> anyhow::Result<DaemonStartReport> {
|
||||
let resource_output_root = options.config.output_root.clone();
|
||||
let resource_output_root = normalized_abs_path(&options.config.output_root)?;
|
||||
let state_dir = options.state_dir.clone();
|
||||
let args = daemon_child_args(options);
|
||||
start_daemon_with_args(state_dir, resource_output_root, args, "后台同步已启动")
|
||||
@@ -993,14 +1224,17 @@ fn start_daemon_with_args(
|
||||
let log_path = daemon_log_path(&state_dir);
|
||||
let socket_path = daemon_socket_path(&state_dir);
|
||||
|
||||
if let Some(existing_pid) = read_pid_file(&pid_path)? {
|
||||
if process_exists(existing_pid) {
|
||||
match classify_pid_lock_file(&pid_path)? {
|
||||
PidLockState::Active(existing_pid) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"官方同步后台进程已经在运行,pid={existing_pid}"
|
||||
));
|
||||
}
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
PidLockState::StalePid(_) | PidLockState::Corrupt => {
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let _ = fs::remove_file(&socket_path);
|
||||
}
|
||||
PidLockState::Missing => {}
|
||||
}
|
||||
|
||||
let executable = env::current_exe()?;
|
||||
@@ -1118,25 +1352,41 @@ fn stop_daemon(state_dir: &Path, output_format: OutputFormat) -> anyhow::Result<
|
||||
|
||||
fn stop_daemon_inner(state_dir: &Path) -> anyhow::Result<DaemonStopReport> {
|
||||
let pid_path = daemon_pid_path(state_dir);
|
||||
let Some(pid) = read_pid_file(&pid_path)? else {
|
||||
return Ok(DaemonStopReport {
|
||||
status: "not_running",
|
||||
message: "后台进程当前未运行",
|
||||
stopped: false,
|
||||
pid: None,
|
||||
state_dir: state_dir.to_path_buf(),
|
||||
pid_path,
|
||||
socket_path: daemon_socket_path(state_dir),
|
||||
});
|
||||
};
|
||||
|
||||
let running = process_exists(pid);
|
||||
if running {
|
||||
terminate_process(pid)?;
|
||||
if !wait_for_process_exit(pid, Duration::from_secs(5)) {
|
||||
return Err(anyhow::anyhow!("后台进程 pid={pid} 在 5 秒内未停止"));
|
||||
let pid_state = classify_pid_lock_file(&pid_path)?;
|
||||
let (pid, running) = match pid_state {
|
||||
PidLockState::Missing => {
|
||||
return Ok(DaemonStopReport {
|
||||
status: "not_running",
|
||||
message: "后台进程当前未运行",
|
||||
stopped: false,
|
||||
pid: None,
|
||||
state_dir: state_dir.to_path_buf(),
|
||||
pid_path,
|
||||
socket_path: daemon_socket_path(state_dir),
|
||||
});
|
||||
}
|
||||
}
|
||||
PidLockState::Corrupt => {
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let _ = fs::remove_file(daemon_socket_path(state_dir));
|
||||
return Ok(DaemonStopReport {
|
||||
status: "stale_pid_removed",
|
||||
message: "已清理无效的后台 PID 文件",
|
||||
stopped: false,
|
||||
pid: None,
|
||||
state_dir: state_dir.to_path_buf(),
|
||||
pid_path,
|
||||
socket_path: daemon_socket_path(state_dir),
|
||||
});
|
||||
}
|
||||
PidLockState::StalePid(pid) => (pid, false),
|
||||
PidLockState::Active(pid) => {
|
||||
terminate_process(pid)?;
|
||||
if !wait_for_process_exit(pid, Duration::from_secs(5)) {
|
||||
return Err(anyhow::anyhow!("后台进程 pid={pid} 在 5 秒内未停止"));
|
||||
}
|
||||
(pid, true)
|
||||
}
|
||||
};
|
||||
let _ = fs::remove_file(&pid_path);
|
||||
let _ = fs::remove_file(daemon_socket_path(state_dir));
|
||||
|
||||
@@ -1164,14 +1414,26 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
|
||||
let status_path = daemon_status_path(state_dir);
|
||||
let socket_path = daemon_socket_path(state_dir);
|
||||
let status_file = read_daemon_status_file(&status_path)?;
|
||||
let pid = read_pid_file(&pid_path)?.or_else(|| status_file.as_ref().map(|status| status.pid));
|
||||
let pid_file_state = classify_pid_lock_file(&pid_path)?;
|
||||
let pid_file_pid = match pid_file_state {
|
||||
PidLockState::Active(pid) | PidLockState::StalePid(pid) => Some(pid),
|
||||
PidLockState::Missing | PidLockState::Corrupt => None,
|
||||
};
|
||||
let pid = pid_file_pid.or_else(|| status_file.as_ref().map(|status| status.pid));
|
||||
let running = pid.map(process_exists).unwrap_or(false);
|
||||
let rpc_available = daemon_rpc_available(state_dir);
|
||||
let stale_pid_file = matches!(
|
||||
pid_file_state,
|
||||
PidLockState::StalePid(_) | PidLockState::Corrupt
|
||||
);
|
||||
let stale_socket = socket_path.exists() && !rpc_available;
|
||||
|
||||
Ok(DaemonStatusReport {
|
||||
status: if running { "running" } else { "stopped" },
|
||||
message: if running {
|
||||
"后台进程正在运行"
|
||||
} else if stale_pid_file || stale_socket {
|
||||
"后台进程当前未运行,但检测到失效 PID/socket;可执行 clean-stable 清理"
|
||||
} else {
|
||||
"后台进程当前未运行"
|
||||
},
|
||||
@@ -1185,6 +1447,8 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
|
||||
status_path,
|
||||
socket_path,
|
||||
rpc_available,
|
||||
stale_pid_file,
|
||||
stale_socket,
|
||||
log_path: status_file
|
||||
.as_ref()
|
||||
.map(|status| status.log_path.clone())
|
||||
@@ -1266,7 +1530,7 @@ fn run_daemon_restart(options: &CliOptions, command_name: &'static str) -> anyho
|
||||
start_options.watch = false;
|
||||
(
|
||||
options.state_dir.clone(),
|
||||
options.config.output_root.clone(),
|
||||
normalized_abs_path(&options.config.output_root)?,
|
||||
daemon_child_args(&start_options),
|
||||
"start_with_explicit_options",
|
||||
)
|
||||
@@ -1340,6 +1604,7 @@ fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow:
|
||||
if refresh_should_use_daemon_rpc(options, command_name)
|
||||
&& daemon_rpc_available(&options.state_dir)
|
||||
{
|
||||
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
|
||||
let report = daemon_rpc_call(
|
||||
&options.state_dir,
|
||||
RPC_METHOD_REFRESH,
|
||||
@@ -1349,6 +1614,7 @@ fn run_sync_command(options: &CliOptions, command_name: &'static str) -> anyhow:
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
assert_no_live_daemon_output_conflict(options, command_name)?;
|
||||
let mut config = options.config.clone();
|
||||
if command_name == "repair" {
|
||||
config.repair = true;
|
||||
@@ -1430,6 +1696,8 @@ fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> {
|
||||
print_json_field(value, "pid", "PID");
|
||||
print_json_field(value, "daemon_state", "后台状态");
|
||||
print_json_field(value, "rpc_available", "RPC 可用");
|
||||
print_json_field(value, "stale_pid_file", "失效 PID");
|
||||
print_json_field(value, "stale_socket", "失效 socket");
|
||||
print_json_field(value, "last_update_status", "上次同步");
|
||||
print_json_field(value, "last_error", "上次错误");
|
||||
print_json_field(value, "next_retry_seconds", "下次重试秒数");
|
||||
@@ -1658,6 +1926,8 @@ impl HumanReport for DaemonStatusReport {
|
||||
print_optional_field("PID", self.pid);
|
||||
print_optional_field("后台状态", self.daemon_state.as_deref());
|
||||
print_field("RPC 可用", format_bool(self.rpc_available));
|
||||
print_field("失效 PID", format_bool(self.stale_pid_file));
|
||||
print_field("失效 socket", format_bool(self.stale_socket));
|
||||
print_optional_field("上次同步", self.last_update_status.as_deref());
|
||||
print_optional_field("上次错误", self.last_error.as_deref());
|
||||
print_optional_field("下次重试秒数", self.next_retry_seconds);
|
||||
@@ -2051,27 +2321,70 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
});
|
||||
|
||||
let lock_path = options.config.lock_path();
|
||||
if lock_path.exists() {
|
||||
let lock_owner = fs::read_to_string(&lock_path)
|
||||
.ok()
|
||||
.and_then(|contents| contents.trim().parse::<u32>().ok());
|
||||
let active = lock_owner.map(process_exists).unwrap_or(true);
|
||||
checks.push(DoctorCheck {
|
||||
name: "resource_lock",
|
||||
ok: !active,
|
||||
message: if active {
|
||||
format!("资源目录锁正在使用:{}", lock_path.display())
|
||||
} else {
|
||||
format!("发现失效资源锁:{}", lock_path.display())
|
||||
},
|
||||
});
|
||||
} else {
|
||||
checks.push(DoctorCheck {
|
||||
checks.push(match classify_pid_lock_file(&lock_path)? {
|
||||
PidLockState::Missing => DoctorCheck {
|
||||
name: "resource_lock",
|
||||
ok: true,
|
||||
message: "资源目录没有活动锁".to_string(),
|
||||
});
|
||||
}
|
||||
},
|
||||
PidLockState::Active(pid) => DoctorCheck {
|
||||
name: "resource_lock",
|
||||
ok: false,
|
||||
message: format!(
|
||||
"资源目录锁正在使用:{},owner_pid={pid}",
|
||||
lock_path.display()
|
||||
),
|
||||
},
|
||||
PidLockState::StalePid(pid) => DoctorCheck {
|
||||
name: "resource_lock",
|
||||
ok: false,
|
||||
message: format!(
|
||||
"发现失效资源锁:{},owner_pid={pid};可执行 clean-stable 清理",
|
||||
lock_path.display()
|
||||
),
|
||||
},
|
||||
PidLockState::Corrupt => DoctorCheck {
|
||||
name: "resource_lock",
|
||||
ok: false,
|
||||
message: format!(
|
||||
"发现损坏资源锁:{};可执行 clean-stable 清理",
|
||||
lock_path.display()
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
let control_lock_path = daemon_control_lock_path(&options.state_dir);
|
||||
checks.push(match classify_pid_lock_file(&control_lock_path)? {
|
||||
PidLockState::Missing => DoctorCheck {
|
||||
name: "daemon_control_lock",
|
||||
ok: true,
|
||||
message: "后台控制锁没有活动锁".to_string(),
|
||||
},
|
||||
PidLockState::Active(pid) => DoctorCheck {
|
||||
name: "daemon_control_lock",
|
||||
ok: true,
|
||||
message: format!(
|
||||
"后台控制命令正在运行:{},owner_pid={pid}",
|
||||
control_lock_path.display()
|
||||
),
|
||||
},
|
||||
PidLockState::StalePid(pid) => DoctorCheck {
|
||||
name: "daemon_control_lock",
|
||||
ok: false,
|
||||
message: format!(
|
||||
"发现失效后台控制锁:{},owner_pid={pid};可执行 clean-stable 清理",
|
||||
control_lock_path.display()
|
||||
),
|
||||
},
|
||||
PidLockState::Corrupt => DoctorCheck {
|
||||
name: "daemon_control_lock",
|
||||
ok: false,
|
||||
message: format!(
|
||||
"发现损坏后台控制锁:{};可执行 clean-stable 清理",
|
||||
control_lock_path.display()
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
let status_path = daemon_status_path(&options.state_dir);
|
||||
checks.push(DoctorCheck {
|
||||
@@ -2167,8 +2480,10 @@ fn run_clean_stable_command(options: &CliOptions) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
let pid_path = daemon_pid_path(&options.state_dir);
|
||||
if let Some(pid) = read_pid_file(&pid_path)? {
|
||||
if !process_exists(pid) {
|
||||
match classify_pid_lock_file(&pid_path)? {
|
||||
PidLockState::Missing => {}
|
||||
PidLockState::Active(_) => skipped_paths.push(pid_path),
|
||||
PidLockState::StalePid(_) | PidLockState::Corrupt => {
|
||||
fs::remove_file(&pid_path)?;
|
||||
removed_paths.push(pid_path);
|
||||
}
|
||||
@@ -2185,15 +2500,27 @@ fn run_clean_stable_command(options: &CliOptions) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
let lock_path = options.config.lock_path();
|
||||
if lock_path.exists() {
|
||||
let owner = fs::read_to_string(&lock_path)
|
||||
.ok()
|
||||
.and_then(|contents| contents.trim().parse::<u32>().ok());
|
||||
if owner.is_some_and(|pid| !process_exists(pid)) {
|
||||
match classify_pid_lock_file(&lock_path)? {
|
||||
PidLockState::Missing => {}
|
||||
PidLockState::Active(_) => skipped_paths.push(lock_path),
|
||||
PidLockState::StalePid(_) | PidLockState::Corrupt => {
|
||||
fs::remove_file(&lock_path)?;
|
||||
removed_paths.push(lock_path);
|
||||
} else {
|
||||
skipped_paths.push(lock_path);
|
||||
}
|
||||
}
|
||||
|
||||
let control_lock_path = daemon_control_lock_path(&options.state_dir);
|
||||
match classify_pid_lock_file(&control_lock_path)? {
|
||||
PidLockState::Missing => {}
|
||||
PidLockState::Active(pid) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"后台控制命令已被锁定 (locked):{};owner_pid={pid} 仍在运行",
|
||||
control_lock_path.display()
|
||||
));
|
||||
}
|
||||
PidLockState::StalePid(_) | PidLockState::Corrupt => {
|
||||
fs::remove_file(&control_lock_path)?;
|
||||
removed_paths.push(control_lock_path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2336,6 +2663,10 @@ fn daemon_socket_path(output_root: &Path) -> PathBuf {
|
||||
output_root.join(DAEMON_SOCKET_FILE)
|
||||
}
|
||||
|
||||
fn daemon_control_lock_path(output_root: &Path) -> PathBuf {
|
||||
output_root.join(DAEMON_CONTROL_LOCK_FILE)
|
||||
}
|
||||
|
||||
fn daemon_child_args(options: &CliOptions) -> Vec<String> {
|
||||
let mut args = Vec::new();
|
||||
let config = &options.config;
|
||||
@@ -3505,6 +3836,140 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daemon_control_lock_rejects_active_owner() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
|
||||
let first = DaemonControlLock::acquire(&state_dir).unwrap();
|
||||
let second = DaemonControlLock::acquire(&state_dir).unwrap_err();
|
||||
assert!(second.to_string().contains("locked"));
|
||||
drop(first);
|
||||
assert!(DaemonControlLock::acquire(&state_dir).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daemon_control_lock_recovers_stale_and_corrupt_files() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
fs::create_dir_all(&state_dir).unwrap();
|
||||
let lock_path = daemon_control_lock_path(&state_dir);
|
||||
|
||||
fs::write(&lock_path, "999999999").unwrap();
|
||||
let lock = DaemonControlLock::acquire(&state_dir).unwrap();
|
||||
drop(lock);
|
||||
assert!(!lock_path.exists());
|
||||
|
||||
fs::write(&lock_path, "not-a-pid").unwrap();
|
||||
let lock = DaemonControlLock::acquire(&state_dir).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(&lock_path).unwrap(),
|
||||
std::process::id().to_string()
|
||||
);
|
||||
drop(lock);
|
||||
assert!(!lock_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_daemon_resource_conflict_detects_same_output() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
let output_root = temp.path().join("resources");
|
||||
fs::create_dir_all(&output_root).unwrap();
|
||||
write_daemon_status_file(
|
||||
&daemon_status_path(&state_dir),
|
||||
&test_daemon_status_file(&state_dir, &output_root),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(daemon_pid_path(&state_dir), std::process::id().to_string()).unwrap();
|
||||
|
||||
let conflict = live_daemon_resource_conflict(&state_dir, &output_root)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(conflict.pid, Some(std::process::id()));
|
||||
assert_eq!(
|
||||
conflict.target_root,
|
||||
normalized_abs_path(&output_root).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_daemon_resource_conflict_allows_different_output() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
let daemon_output = temp.path().join("daemon-resources");
|
||||
let manual_output = temp.path().join("manual-resources");
|
||||
fs::create_dir_all(&daemon_output).unwrap();
|
||||
fs::create_dir_all(&manual_output).unwrap();
|
||||
write_daemon_status_file(
|
||||
&daemon_status_path(&state_dir),
|
||||
&test_daemon_status_file(&state_dir, &daemon_output),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(daemon_pid_path(&state_dir), std::process::id().to_string()).unwrap();
|
||||
|
||||
assert!(live_daemon_resource_conflict(&state_dir, &manual_output)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_write_rejects_live_daemon_for_same_output() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
let output_root = temp.path().join("resources");
|
||||
fs::create_dir_all(&output_root).unwrap();
|
||||
write_daemon_status_file(
|
||||
&daemon_status_path(&state_dir),
|
||||
&test_daemon_status_file(&state_dir, &output_root),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(daemon_pid_path(&state_dir), std::process::id().to_string()).unwrap();
|
||||
let options = CliOptions {
|
||||
state_dir,
|
||||
config: OfficialUpdateConfig {
|
||||
output_root,
|
||||
..OfficialUpdateConfig::default()
|
||||
},
|
||||
..CliOptions::default()
|
||||
};
|
||||
|
||||
let error = assert_no_live_daemon_output_conflict(&options, "repair").unwrap_err();
|
||||
assert!(error.to_string().contains("同一资源目录"));
|
||||
assert!(error.to_string().contains("locked"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_stable_removes_corrupt_pid_and_lock_files() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let output_root = temp.path().join("resources");
|
||||
let state_dir = temp.path().join("state");
|
||||
fs::create_dir_all(&output_root).unwrap();
|
||||
fs::create_dir_all(&state_dir).unwrap();
|
||||
let resource_lock = output_root.join(".official-sync.lock");
|
||||
let control_lock = daemon_control_lock_path(&state_dir);
|
||||
let pid_path = daemon_pid_path(&state_dir);
|
||||
fs::write(&resource_lock, "not-a-pid").unwrap();
|
||||
fs::write(&control_lock, "not-a-pid").unwrap();
|
||||
fs::write(&pid_path, "not-a-pid").unwrap();
|
||||
|
||||
let options = CliOptions {
|
||||
command: CliCommand::CleanStable,
|
||||
output_format: OutputFormat::Json,
|
||||
state_dir,
|
||||
config: OfficialUpdateConfig {
|
||||
output_root,
|
||||
..OfficialUpdateConfig::default()
|
||||
},
|
||||
..CliOptions::default()
|
||||
};
|
||||
run_clean_stable_command(&options).unwrap();
|
||||
|
||||
assert!(!resource_lock.exists());
|
||||
assert!(!control_lock.exists());
|
||||
assert!(!pid_path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rpc_stop_wait_reports_already_stopped_without_signal() {
|
||||
assert!(!wait_for_rpc_stop_or_terminate(0, Duration::from_millis(1)).unwrap());
|
||||
@@ -3646,4 +4111,23 @@ mod tests {
|
||||
.saturating_add(second),
|
||||
)
|
||||
}
|
||||
|
||||
fn test_daemon_status_file(state_dir: &Path, output_root: &Path) -> DaemonStatusFile {
|
||||
DaemonStatusFile {
|
||||
version: DAEMON_STATUS_VERSION,
|
||||
pid: std::process::id(),
|
||||
state: "sleeping".to_string(),
|
||||
resource_output_root: output_root.to_path_buf(),
|
||||
state_dir: state_dir.to_path_buf(),
|
||||
log_path: daemon_log_path(state_dir),
|
||||
started_unix_seconds: unix_seconds_now(),
|
||||
updated_unix_seconds: unix_seconds_now(),
|
||||
last_update_status: Some("up_to_date".to_string()),
|
||||
last_error: None,
|
||||
next_retry_seconds: Some(60),
|
||||
pending_scheduled_force: false,
|
||||
next_forced_refresh_unix_seconds: None,
|
||||
command: vec!["bat".to_string(), "--daemon-child".to_string()],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1459,8 +1459,9 @@ impl OfficialUpdateLock {
|
||||
continue;
|
||||
}
|
||||
return Err(anyhow::anyhow!(
|
||||
"官方资源目录已被锁定 (locked):{}",
|
||||
path.display()
|
||||
"官方资源目录已被锁定 (locked):{};{}",
|
||||
path.display(),
|
||||
describe_lock_owner(&path)
|
||||
));
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -1481,14 +1482,21 @@ impl OfficialUpdateLock {
|
||||
|
||||
impl Drop for OfficialUpdateLock {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
let expected = std::process::id().to_string();
|
||||
if fs::read_to_string(&self.path)
|
||||
.map(|contents| contents.trim() == expected)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_stale_lock(path: &Path) -> anyhow::Result<bool> {
|
||||
let contents = fs::read_to_string(path).unwrap_or_default();
|
||||
let Some(pid) = parse_lock_pid(&contents) else {
|
||||
return Ok(false);
|
||||
fs::remove_file(path)?;
|
||||
return Ok(true);
|
||||
};
|
||||
if process_exists(pid) {
|
||||
return Ok(false);
|
||||
@@ -1498,6 +1506,15 @@ fn remove_stale_lock(path: &Path) -> anyhow::Result<bool> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn describe_lock_owner(path: &Path) -> String {
|
||||
let contents = fs::read_to_string(path).unwrap_or_default();
|
||||
match parse_lock_pid(&contents) {
|
||||
Some(pid) if process_exists(pid) => format!("owner_pid={pid} 仍在运行"),
|
||||
Some(pid) => format!("owner_pid={pid} 已失效,可重新执行或 clean-stable 清理"),
|
||||
None => "锁文件内容不是有效 PID,可执行 clean-stable 清理".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_lock_pid(contents: &str) -> Option<u32> {
|
||||
contents.trim().parse::<u32>().ok().filter(|pid| *pid > 0)
|
||||
}
|
||||
@@ -1716,6 +1733,25 @@ mod tests {
|
||||
assert!(!config.lock_path().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_dry_run_lock_removes_corrupt_pid_file() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let config = OfficialUpdateConfig {
|
||||
output_root: temp.path().to_path_buf(),
|
||||
..OfficialUpdateConfig::default()
|
||||
};
|
||||
fs::create_dir_all(&config.output_root).unwrap();
|
||||
fs::write(config.lock_path(), "not-a-pid").unwrap();
|
||||
|
||||
let lock = OfficialUpdateLock::acquire(&config).unwrap();
|
||||
assert_eq!(
|
||||
fs::read_to_string(config.lock_path()).unwrap(),
|
||||
std::process::id().to_string()
|
||||
);
|
||||
drop(lock);
|
||||
assert!(!config.lock_path().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_run_can_be_cancelled_before_network_work() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user