fix: serialize daemon control commands

Fixes #10
This commit is contained in:
2026-07-13 22:02:11 +08:00
parent 1bcb9c828a
commit 599ead8391
9 changed files with 593 additions and 72 deletions
+536 -52
View File
@@ -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()],
}
}
}
+40 -4
View File
@@ -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();