Release v0.2.1

This commit is contained in:
burnintuna 2026-07-22 14:42:23 +09:00
parent ffa8bc93ba
commit 048df6355e
10 changed files with 1467 additions and 254 deletions

View file

@ -1,4 +1,4 @@
use std::cell::RefCell;
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::fs::{self, File};
use std::path::{Path, PathBuf};
@ -24,10 +24,18 @@ pub struct ArchiveSource {
pub struct ZipCache {
directory: PathBuf,
quota_bytes: u64,
quota_bytes: Cell<u64>,
pinned: RefCell<HashMap<PathBuf, usize>>,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct CacheCleanupReport {
pub removed_files: usize,
pub removed_bytes: u64,
pub retained_open_files: usize,
pub remaining_bytes: u64,
}
struct CachePin {
path: PathBuf,
cache: Weak<ZipCache>,
@ -37,12 +45,18 @@ impl Drop for CachePin {
fn drop(&mut self) {
if let Some(cache) = self.cache.upgrade() {
let mut pinned = cache.pinned.borrow_mut();
let mut released = false;
if let Some(count) = pinned.get_mut(&self.path) {
*count -= 1;
if *count == 0 {
pinned.remove(&self.path);
released = true;
}
}
drop(pinned);
if released {
let _ = cache.enforce_quota(None);
}
}
}
}
@ -52,18 +66,24 @@ impl ZipCache {
fs::create_dir_all(&directory)?;
for entry in fs::read_dir(&directory)? {
let path = entry?.path();
if path
let incomplete_download = path
.extension()
.is_some_and(|extension| extension == "part")
{
.is_some_and(|extension| extension == "part");
let stale_temporary = path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("temporary-"));
if incomplete_download || stale_temporary {
let _ = fs::remove_file(path);
}
}
Ok(Rc::new(Self {
let cache = Rc::new(Self {
directory,
quota_bytes: quota_gb.saturating_mul(1024 * 1024 * 1024),
quota_bytes: Cell::new(quota_gb.saturating_mul(1024 * 1024 * 1024)),
pinned: RefCell::new(HashMap::new()),
}))
});
cache.enforce_quota(None)?;
Ok(cache)
}
pub fn prepare<F, P>(self: &Rc<Self>, entry: BrowserEntry, progress: P, callback: F)
@ -83,7 +103,8 @@ impl ZipCache {
return;
}
if self.quota_bytes == 0 || entry.size.is_some_and(|size| size > self.quota_bytes) {
let quota_bytes = self.quota_bytes.get();
if quota_bytes == 0 || entry.size.is_some_and(|size| size > quota_bytes) {
self.download_temporary(source, entry, true, progress, callback);
} else {
self.download_persistent(source, entry, progress, callback);
@ -127,7 +148,11 @@ impl ZipCache {
|| ".part".to_owned(),
|extension| format!(".part.{extension}"),
);
let temporary = match tempfile::Builder::new().suffix(&suffix).tempfile() {
let temporary = match tempfile::Builder::new()
.prefix("temporary-")
.suffix(&suffix)
.tempfile_in(&self.directory)
{
Ok(file) => Rc::new(file.into_temp_path()),
Err(error) => {
callback(Err(error.into()));
@ -183,6 +208,12 @@ impl ZipCache {
}
let part_path = self.directory.join(format!("{key}.part"));
if let Some(required_bytes) = entry.size
&& let Err(error) = self.make_room_for(required_bytes)
{
callback(Err(error));
return;
}
let destination = gio::File::for_path(&part_path);
let cache = Rc::clone(self);
source.copy_async(
@ -200,7 +231,7 @@ impl ZipCache {
final_path.display()
)
})?;
cache.enforce_quota(&final_path)?;
cache.enforce_quota(Some(&final_path))?;
Ok(cache.lease(final_path, entry.uri, entry.name))
});
if result.is_err() {
@ -211,11 +242,35 @@ impl ZipCache {
);
}
fn enforce_quota(&self, protected: &Path) -> Result<()> {
pub fn clear_persistent(&self) -> Result<CacheCleanupReport> {
self.cleanup_to_limit(0, None)
}
fn make_room_for(&self, required_bytes: u64) -> Result<()> {
let limit = self.quota_bytes.get().saturating_sub(required_bytes);
let report = self.cleanup_to_limit(limit, None)?;
if report.remaining_bytes > limit {
bail!("열려 있는 ZIP 때문에 다운로드에 필요한 캐시 공간을 확보할 수 없습니다");
}
Ok(())
}
fn enforce_quota(&self, protected: Option<&Path>) -> Result<CacheCleanupReport> {
self.cleanup_to_limit(self.quota_bytes.get(), protected)
}
fn cleanup_to_limit(&self, limit: u64, protected: Option<&Path>) -> Result<CacheCleanupReport> {
let mut files = Vec::new();
let mut total = 0_u64;
for entry in fs::read_dir(&self.directory)? {
let path = entry?.path();
if path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("temporary-"))
{
continue;
}
if path.extension().is_none_or(|extension| extension != "zip") {
continue;
}
@ -224,18 +279,26 @@ impl ZipCache {
files.push((metadata.modified().ok(), metadata.len(), path));
}
files.sort_by_key(|(modified, _, path)| (*modified, path.clone()));
let mut report = CacheCleanupReport::default();
for (_, size, path) in files {
if total <= self.quota_bytes {
if total <= limit {
break;
}
if path != protected
&& !self.pinned.borrow().contains_key(&path)
&& fs::remove_file(&path).is_ok()
{
total = total.saturating_sub(size);
if protected == Some(path.as_path()) {
continue;
}
if self.pinned.borrow().contains_key(&path) {
report.retained_open_files += 1;
continue;
}
fs::remove_file(&path)
.with_context(|| format!("캐시 파일을 삭제할 수 없습니다: {}", path.display()))?;
total = total.saturating_sub(size);
report.removed_files += 1;
report.removed_bytes = report.removed_bytes.saturating_add(size);
}
Ok(())
report.remaining_bytes = total;
Ok(report)
}
fn lease(
@ -351,7 +414,7 @@ mod tests {
fs::write(&second, [0_u8; 8]).unwrap();
let cache = Rc::new(ZipCache {
directory: directory.path().to_path_buf(),
quota_bytes: 8,
quota_bytes: Cell::new(8),
pinned: RefCell::new(HashMap::new()),
});
@ -360,13 +423,104 @@ mod tests {
"file:///first.zip".into(),
"first.zip".into(),
);
cache.enforce_quota(&second).unwrap();
cache.enforce_quota(Some(&second)).unwrap();
assert!(first.exists());
assert!(second.exists());
drop(lease);
cache.enforce_quota(&second).unwrap();
cache.enforce_quota(Some(&second)).unwrap();
assert!(!first.exists());
assert!(second.exists());
}
#[test]
fn lru_removes_oldest_cache_file_first() {
let directory = tempfile::tempdir().unwrap();
let oldest = directory.path().join("oldest.zip");
let newest = directory.path().join("newest.zip");
fs::write(&oldest, [0_u8; 8]).unwrap();
fs::write(&newest, [0_u8; 8]).unwrap();
filetime::set_file_mtime(&oldest, FileTime::from_unix_time(1, 0)).unwrap();
filetime::set_file_mtime(&newest, FileTime::from_unix_time(2, 0)).unwrap();
let cache = ZipCache {
directory: directory.path().to_path_buf(),
quota_bytes: Cell::new(8),
pinned: RefCell::new(HashMap::new()),
};
let report = cache.enforce_quota(None).unwrap();
assert_eq!(report.removed_files, 1);
assert!(!oldest.exists());
assert!(newest.exists());
}
#[test]
fn download_reserves_space_before_writing() {
let directory = tempfile::tempdir().unwrap();
let oldest = directory.path().join("oldest.zip");
let newest = directory.path().join("newest.zip");
fs::write(&oldest, [0_u8; 8]).unwrap();
fs::write(&newest, [0_u8; 8]).unwrap();
filetime::set_file_mtime(&oldest, FileTime::from_unix_time(1, 0)).unwrap();
filetime::set_file_mtime(&newest, FileTime::from_unix_time(2, 0)).unwrap();
let cache = ZipCache {
directory: directory.path().to_path_buf(),
quota_bytes: Cell::new(16),
pinned: RefCell::new(HashMap::new()),
};
cache.make_room_for(8).unwrap();
assert!(!oldest.exists());
assert!(newest.exists());
}
#[test]
fn zero_quota_clears_existing_cache_on_startup() {
let directory = tempfile::tempdir().unwrap();
let cached = directory.path().join("cached.zip");
fs::write(&cached, [0_u8; 8]).unwrap();
let _cache = ZipCache::new(directory.path().to_path_buf(), 0).unwrap();
assert!(!cached.exists());
}
#[test]
fn startup_removes_stale_temporary_downloads() {
let directory = tempfile::tempdir().unwrap();
let temporary = directory.path().join("temporary-abcd.part.mp4");
fs::write(&temporary, [0_u8; 8]).unwrap();
let _cache = ZipCache::new(directory.path().to_path_buf(), 10).unwrap();
assert!(!temporary.exists());
}
#[test]
fn manual_clear_keeps_open_archives() {
let directory = tempfile::tempdir().unwrap();
let open = directory.path().join("open.zip");
let unused = directory.path().join("unused.zip");
let temporary = directory.path().join("temporary-download.part.zip");
fs::write(&open, [0_u8; 8]).unwrap();
fs::write(&unused, [0_u8; 8]).unwrap();
fs::write(&temporary, [0_u8; 8]).unwrap();
let cache = Rc::new(ZipCache {
directory: directory.path().to_path_buf(),
quota_bytes: Cell::new(16),
pinned: RefCell::new(HashMap::new()),
});
let lease = cache.lease(open.clone(), "file:///open.zip".into(), "open.zip".into());
let report = cache.clear_persistent().unwrap();
assert_eq!(report.removed_files, 1);
assert_eq!(report.retained_open_files, 1);
assert!(open.exists());
assert!(!unused.exists());
assert!(temporary.exists());
drop(lease);
}
}