fix(acp): bypass Windows file locks when clearing binary cache

clear_agent_cache falls back to renaming the agent cache directory
to <cache_dir>/.trash/<agent_id>-<nanos>-<counter>/ when
remove_dir_all fails — typically because a running <cmd>.exe or a
Defender scan holds the file. NTFS rename succeeds on directories
whose children are locked, so Upgrade and Uninstall no longer
surface ERROR_ACCESS_DENIED on Windows.

A detached OS thread sweeps .trash/ at startup with all errors
swallowed, panics caught, and no shared state — cannot block app
startup or leak threads. Still-locked entries are left for the
next launch.
This commit is contained in:
xintaofei
2026-04-25 15:56:47 +08:00
parent f0bd2a28a2
commit f264f560b1
3 changed files with 80 additions and 4 deletions

View File

@@ -1,11 +1,18 @@
use std::collections::HashSet;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::acp::error::AcpError;
use crate::acp::registry;
use crate::models::agent::AgentType;
/// Process-local counter appended to rename-aside trash directory names. Guards
/// against the rare case where two `clear_agent_cache` calls land in the same
/// `SystemTime::now()` tick (Windows `GetSystemTimePreciseAsFileTime` has ~100ns
/// resolution) and would otherwise collide on the rename target.
static TRASH_COUNTER: AtomicU64 = AtomicU64::new(0);
pub(crate) fn cache_dir() -> Result<PathBuf, AcpError> {
let base = dirs::cache_dir()
.ok_or_else(|| AcpError::DownloadFailed("cannot determine cache directory".into()))?;
@@ -44,14 +51,56 @@ pub(crate) fn binary_dir(agent_id: &str, version: &str) -> Result<PathBuf, AcpEr
pub fn clear_agent_cache(agent_type: AgentType) -> Result<(), AcpError> {
let agent_id = agent_cache_key(agent_type);
let dir = cache_dir()?.join(agent_id);
if dir.exists() {
std::fs::remove_dir_all(&dir)
.map_err(|e| AcpError::DownloadFailed(format!("failed to clear cache: {e}")))?;
let dir = cache_dir()?.join(&agent_id);
if !dir.exists() {
return Ok(());
}
if std::fs::remove_dir_all(&dir).is_ok() {
return Ok(());
}
// Windows: a running `<cmd>.exe` (ours or anti-virus scanning it) keeps the
// file locked, so `remove_dir_all` returns ERROR_ACCESS_DENIED. NTFS allows
// renaming a directory whose children are locked because rename only
// updates the parent directory entry; the locked file's FILE_OBJECT keeps
// working under the new path. The aside is swept on next startup.
let trash_root = cache_dir()?.join(".trash");
let _ = std::fs::create_dir_all(&trash_root);
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let counter = TRASH_COUNTER.fetch_add(1, Ordering::Relaxed);
let aside = trash_root.join(format!("{agent_id}-{stamp}-{counter}"));
std::fs::rename(&dir, &aside)
.map_err(|e| AcpError::DownloadFailed(format!("failed to clear cache: {e}")))?;
let _ = std::fs::remove_dir_all(&aside);
Ok(())
}
/// Best-effort cleanup of trash directories left behind by
/// `clear_agent_cache`'s rename-aside fallback. Designed to be run from a
/// detached OS thread at startup: every error path is silently swallowed,
/// no logs, no panics escape, no subprocesses spawned. Whatever cannot be
/// removed (e.g. a binary still locked by an external process) is left for
/// the next startup.
///
/// Iterates children rather than nuking the parent so that a concurrent
/// `clear_agent_cache` racing to rename a fresh entry into `.trash/` cannot
/// have its target directory yanked out from under it.
pub fn sweep_trash() {
let Ok(base) = cache_dir() else { return };
let trash = base.join(".trash");
let Ok(entries) = std::fs::read_dir(&trash) else {
return;
};
for entry in entries.flatten() {
let _ = std::fs::remove_dir_all(entry.path());
}
}
fn installed_binary_path(agent_id: &str, version: &str, cmd_name: &str) -> Option<PathBuf> {
let bin_name = if cfg!(target_os = "windows") {
format!("{cmd_name}.exe")