| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | |
|
| 9 | |
|
| 10 | |
|
| 11 | |
|
| 12 | |
|
| 13 | |
|
| 14 | |
|
| 15 | |
|
| 16 | |
|
| 17 | |
|
| 18 | |
|
| 19 | |
|
| 20 | |
|
| 21 | |
|
| 22 | |
|
| 23 | |
|
| 24 | |
|
| 25 | |
|
| 26 | |
|
| 27 | |
|
| 28 | |
|
| 29 | |
|
| 30 | |
|
| 31 | |
|
| 32 | | use serde::{Deserialize, Serialize}; |
| 33 | | #[cfg(unix)] |
| 34 | | use std::collections::{HashMap, HashSet}; |
| 35 | | #[cfg(unix)] |
| 36 | | use std::os::unix::fs::MetadataExt; |
| 37 | | use std::path::Path; |
| 38 | | #[cfg(unix)] |
| 39 | | use std::path::PathBuf; |
| 40 | | use std::time::{Duration, SystemTime}; |
| 41 | |
|
| 42 | |
|
| 43 | |
|
| 44 | | pub const DEFAULT_PERCENT: u8 = 60; |
| 45 | |
|
| 46 | |
|
| 47 | | #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] |
| 48 | | pub struct Pruned { |
| 49 | | |
| 50 | | pub size: u64, |
| 51 | | pub percent: u8, |
| 52 | | |
| 53 | | pub before: u64, |
| 54 | | pub after: u64, |
| 55 | | |
| 56 | | pub freed: u64, |
| 57 | | pub files: u64, |
| 58 | | |
| 59 | | |
| 60 | | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 61 | | pub unpruned: Option<String>, |
| 62 | | } |
| 63 | |
|
| 64 | | impl Pruned { |
| 65 | | |
| 66 | 54× | pub fn cap(&self) -> u64 { |
| 67 | 54× | cap(self.size, self.percent) |
| 68 | 54× | } |
| 69 | |
|
| 70 | | |
| 71 | 35× | pub fn fits(&self) -> bool { |
| 72 | 35× | self.after <= self.cap() |
| 73 | 35× | } |
| 74 | | } |
| 75 | |
|
| 76 | 54× | fn cap(size: u64, percent: u8) -> u64 { |
| 77 | 54× | (size as u128 * percent.min(100) as u128 / 100) as u64 |
| 78 | 54× | } |
| 79 | |
|
| 80 | |
|
| 81 | |
|
| 82 | |
|
| 83 | |
|
| 84 | 0× | pub fn start_clock() -> SystemTime { |
| 85 | 0× | std::thread::sleep(Duration::from_millis(20)); |
| 86 | 0× | let t = SystemTime::now(); |
| 87 | 0× | std::thread::sleep(Duration::from_millis(20)); |
| 88 | 0× | t |
| 89 | 0× | } |
| 90 | |
|
| 91 | | #[cfg(unix)] |
| 92 | |
|
| 93 | 7× | pub fn records_reads(path: &Path) -> bool { |
| 94 | 7× | let Ok(c) = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()) else { return false }; |
| 95 | 7× | let mut st: libc::statvfs = unsafe { std::mem::zeroed() }; |
| 96 | 7× | if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 { |
| 97 | 0× | return false; |
| 98 | 7× | } |
| 99 | 7× | st.f_flag & libc::ST_NOATIME == 0 |
| 100 | 7× | } |
| 101 | |
|
| 102 | | #[cfg(unix)] |
| 103 | |
|
| 104 | |
|
| 105 | 0× | pub fn usage(path: &Path) -> std::io::Result<(u64, u64)> { |
| 106 | 0× | let c = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())?; |
| 107 | 0× | let mut st: libc::statvfs = unsafe { std::mem::zeroed() }; |
| 108 | 0× | if unsafe { libc::statvfs(c.as_ptr(), &mut st) } != 0 { |
| 109 | 0× | return Err(std::io::Error::last_os_error()); |
| 110 | 0× | } |
| 111 | 0× | let unit = st.f_frsize as u64; |
| 112 | 0× | Ok(((st.f_blocks as u64).saturating_sub(st.f_bfree as u64) * unit, st.f_blocks as u64 * unit)) |
| 113 | 0× | } |
| 114 | |
|
| 115 | | #[cfg(unix)] |
| 116 | |
|
| 117 | |
|
| 118 | |
|
| 119 | |
|
| 120 | 2× | pub fn forget_reads(root: &Path) -> std::io::Result<u64> { |
| 121 | 2× | let mut n = 0; |
| 122 | 2× | let mut err = None; |
| 123 | 6× | walk(root, &mut |p, md| { |
| 124 | 6× | if !md.file_type().is_file() { |
| 125 | 4× | return; |
| 126 | 2× | } |
| 127 | 2× | let Ok(c) = std::ffi::CString::new(p.as_os_str().as_encoded_bytes()) else { return }; |
| 128 | 2× | let times = [libc::timespec { tv_sec: 0, tv_nsec: 0 }, libc::timespec { tv_sec: 0, tv_nsec: libc::UTIME_OMIT }]; |
| 129 | 2× | if unsafe { libc::utimensat(libc::AT_FDCWD, c.as_ptr(), times.as_ptr(), libc::AT_SYMLINK_NOFOLLOW) } == 0 { |
| 130 | 2× | n += 1; |
| 131 | 2× | } else if err.is_none() { |
| 132 | 0× | err = Some(std::io::Error::last_os_error()); |
| 133 | 0× | } |
| 134 | 6× | })?; |
| 135 | 2× | match err { |
| 136 | 0× | Some(e) => Err(e), |
| 137 | 2× | None => Ok(n), |
| 138 | | } |
| 139 | 2× | } |
| 140 | |
|
| 141 | | #[cfg(unix)] |
| 142 | |
|
| 143 | |
|
| 144 | |
|
| 145 | 6× | fn walk(root: &Path, f: &mut dyn FnMut(&Path, &std::fs::Metadata)) -> std::io::Result<()> { |
| 146 | 6× | let dev = std::fs::symlink_metadata(root)?.dev(); |
| 147 | 6× | let mut dirs = vec![root.to_path_buf()]; |
| 148 | 38× | while let Some(d) = dirs.pop() { |
| 149 | 32× | let Ok(entries) = std::fs::read_dir(&d) else { continue }; |
| 150 | 51× | for e in entries.flatten() { |
| 151 | 51× | let p = e.path(); |
| 152 | 51× | let Ok(md) = std::fs::symlink_metadata(&p) else { continue }; |
| 153 | 51× | if md.dev() != dev { |
| 154 | 0× | continue; |
| 155 | 51× | } |
| 156 | 51× | f(&p, &md); |
| 157 | 51× | if md.is_dir() { |
| 158 | 26× | dirs.push(p); |
| 159 | 26× | } |
| 160 | | } |
| 161 | | } |
| 162 | 6× | Ok(()) |
| 163 | 6× | } |
| 164 | |
|
| 165 | | #[cfg(unix)] |
| 166 | |
|
| 167 | |
|
| 168 | 29× | fn hashes(rel: &Path) -> impl Iterator<Item = String> + '_ { |
| 169 | 89× | rel.components().filter_map(|c| c.as_os_str().to_str()).flat_map(|name| { |
| 170 | 89× | name.split('-').skip(1).filter_map(|w| { |
| 171 | 33× | let w = w.split('.').next().unwrap_or(""); |
| 172 | 347× | (w.len() == 16 && w.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))).then(|| w.to_string()) |
| 173 | 33× | }) |
| 174 | 89× | }) |
| 175 | 29× | } |
| 176 | |
|
| 177 | | #[cfg(unix)] |
| 178 | 106× | fn nanos(s: i64, ns: i64) -> i128 { |
| 179 | 106× | s as i128 * 1_000_000_000 + ns as i128 |
| 180 | 106× | } |
| 181 | |
|
| 182 | | #[cfg(unix)] |
| 183 | |
|
| 184 | | struct File { |
| 185 | | paths: Vec<PathBuf>, |
| 186 | | links: u64, |
| 187 | | bytes: u64, |
| 188 | | |
| 189 | | |
| 190 | | last: i128, |
| 191 | | used: bool, |
| 192 | | } |
| 193 | |
|
| 194 | | #[cfg(unix)] |
| 195 | |
|
| 196 | |
|
| 197 | |
|
| 198 | |
|
| 199 | |
|
| 200 | |
|
| 201 | |
|
| 202 | 10× | pub fn prune(root: &Path, started: SystemTime, keep: &[String], size: u64, before: u64, percent: u8) -> std::io::Result<Pruned> { |
| 203 | 10× | let mut out = Pruned { size, percent, before, after: before, ..Pruned::default() }; |
| 204 | 10× | if before <= out.cap() { |
| 205 | 6× | return Ok(out); |
| 206 | 4× | } |
| 207 | 4× | let since = started.duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_nanos() as i128).unwrap_or(0); |
| 208 | 4× | let keep: Vec<PathBuf> = keep.iter().map(|k| PathBuf::from(k.trim_start_matches('/'))).filter(|k| !k.as_os_str().is_empty()).collect(); |
| 209 | 4× | let mut files: HashMap<(u64, u64), File> = HashMap::new(); |
| 210 | 45× | walk(root, &mut |p, md| { |
| 211 | 45× | if !md.file_type().is_file() { |
| 212 | 23× | return; |
| 213 | 22× | } |
| 214 | 22× | let rel = p.strip_prefix(root).unwrap_or(p); |
| 215 | 22× | let used = [nanos(md.atime(), md.atime_nsec()), nanos(md.mtime(), md.mtime_nsec()), nanos(md.ctime(), md.ctime_nsec())] |
| 216 | 22× | .into_iter() |
| 217 | 45× | .any(|t| t >= since) |
| 218 | 11× | || keep.iter().any(|k| rel.starts_with(k)); |
| 219 | 22× | let f = files.entry((md.dev(), md.ino())).or_insert_with(|| File { |
| 220 | 20× | paths: Vec::new(), |
| 221 | 20× | links: md.nlink(), |
| 222 | 20× | bytes: md.blocks() * 512, |
| 223 | 20× | last: nanos(md.atime(), md.atime_nsec()).max(nanos(md.mtime(), md.mtime_nsec())), |
| 224 | | used: false, |
| 225 | 20× | }); |
| 226 | 22× | f.paths.push(p.to_path_buf()); |
| 227 | 22× | f.used |= used; |
| 228 | 45× | })?; |
| 229 | | |
| 230 | 4× | let live: HashSet<String> = |
| 231 | 13× | files.values().filter(|f| f.used).flat_map(|f| f.paths.iter().flat_map(|p| hashes(p.strip_prefix(root).unwrap_or(p)))).collect(); |
| 232 | 4× | let mut gone: Vec<File> = files |
| 233 | 4× | .into_values() |
| 234 | 20× | .filter(|f| { |
| 235 | 20× | !f.used |
| 236 | | |
| 237 | 9× | && f.links == f.paths.len() as u64 |
| 238 | 9× | && !f.paths.iter().any(|p| hashes(p.strip_prefix(root).unwrap_or(p)).any(|h| live.contains(&h))) |
| 239 | 20× | }) |
| 240 | 4× | .collect(); |
| 241 | 4× | gone.sort_by_key(|f| f.last); |
| 242 | 4× | let mut emptied: HashSet<PathBuf> = HashSet::new(); |
| 243 | 5× | for f in gone { |
| 244 | 5× | if out.after <= out.cap() { |
| 245 | 1× | break; |
| 246 | 4× | } |
| 247 | 4× | let mut removed = true; |
| 248 | 4× | for p in &f.paths { |
| 249 | 4× | if std::fs::remove_file(p).is_err() { |
| 250 | 0× | removed = false; |
| 251 | 4× | } else if let Some(parent) = p.parent() { |
| 252 | 4× | emptied.insert(parent.to_path_buf()); |
| 253 | 4× | } |
| 254 | | } |
| 255 | 4× | if removed { |
| 256 | 4× | out.after = out.after.saturating_sub(f.bytes); |
| 257 | 4× | out.freed += f.bytes; |
| 258 | 4× | out.files += 1; |
| 259 | 4× | } |
| 260 | | } |
| 261 | | |
| 262 | 4× | let mut dirs: Vec<PathBuf> = emptied.into_iter().collect(); |
| 263 | 4× | dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count())); |
| 264 | 4× | for d in dirs { |
| 265 | 4× | let mut d = d.as_path(); |
| 266 | 5× | while d != root && d.starts_with(root) && std::fs::remove_dir(d).is_ok() { |
| 267 | 1× | match d.parent() { |
| 268 | 1× | Some(p) => d = p, |
| 269 | 0× | None => break, |
| 270 | | } |
| 271 | | } |
| 272 | | } |
| 273 | 4× | Ok(out) |
| 274 | 10× | } |
| 275 | |
|
| 276 | |
|
| 277 | |
|
| 278 | |
|
| 279 | | #[cfg(not(unix))] |
| 280 | | pub fn records_reads(_path: &Path) -> bool { |
| 281 | | false |
| 282 | | } |
| 283 | |
|
| 284 | | #[cfg(not(unix))] |
| 285 | | pub fn usage(_path: &Path) -> std::io::Result<(u64, u64)> { |
| 286 | | Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "cache disks are pruned only on Linux")) |
| 287 | | } |
| 288 | |
|
| 289 | | #[cfg(not(unix))] |
| 290 | | pub fn forget_reads(_root: &Path) -> std::io::Result<u64> { |
| 291 | | Ok(0) |
| 292 | | } |
| 293 | |
|
| 294 | | #[cfg(not(unix))] |
| 295 | | pub fn prune(_root: &Path, _started: SystemTime, _keep: &[String], _size: u64, _before: u64, _percent: u8) -> std::io::Result<Pruned> { |
| 296 | | Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "cache disks are pruned only on Linux")) |
| 297 | | } |
| 298 | |
|
| 299 | | #[cfg(all(test, unix))] |
| 300 | | mod tests { |
| 301 | | use super::*; |
| 302 | |
|
| 303 | | |
| 304 | | |
| 305 | | const T: u64 = 4_000_000_000; |
| 306 | |
|
| 307 | 26× | fn at(secs: u64) -> SystemTime { |
| 308 | 26× | SystemTime::UNIX_EPOCH + Duration::from_secs(secs) |
| 309 | 26× | } |
| 310 | |
|
| 311 | | |
| 312 | 11× | fn file(root: &Path, rel: &str, kib: usize, secs: u64) -> PathBuf { |
| 313 | 11× | let p = root.join(rel); |
| 314 | 11× | std::fs::create_dir_all(p.parent().unwrap()).unwrap(); |
| 315 | 1314816× | let bytes: Vec<u8> = (0..kib << 10).map(|i: usize| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect(); |
| 316 | 11× | std::fs::write(&p, bytes).unwrap(); |
| 317 | 11× | let f = std::fs::File::options().write(true).open(&p).unwrap(); |
| 318 | 11× | f.set_times(std::fs::FileTimes::new().set_accessed(at(secs)).set_modified(at(secs))).unwrap(); |
| 319 | 11× | p |
| 320 | 11× | } |
| 321 | |
|
| 322 | 11× | fn bytes(p: &Path) -> u64 { |
| 323 | 11× | std::fs::metadata(p).unwrap().blocks() * 512 |
| 324 | 11× | } |
| 325 | |
|
| 326 | | #[test] |
| 327 | 1× | fn hashes_are_cargos_unit_hashes() { |
| 328 | 7× | let h = |p: &str| hashes(Path::new(p)).collect::<Vec<_>>(); |
| 329 | 1× | assert_eq!(h("debug/deps/libdep-8f2b19826c505967.rlib"), ["8f2b19826c505967"]); |
| 330 | 1× | assert_eq!(h("debug/.fingerprint/dep-8f2b19826c505967/lib-dep"), ["8f2b19826c505967"]); |
| 331 | 1× | assert_eq!(h("debug/build/my-crate-0123456789abcdef/out/x.o"), ["0123456789abcdef"]); |
| 332 | 1× | assert!(h("debug/incremental/app-2dadnjnzlrswc/s-hndl5uwdpw-1uaho4m.lock").is_empty()); |
| 333 | 1× | assert!(h("debug/app").is_empty() && h("debug/deps/x-0123456789ABCDEF").is_empty() && h("x-0123456789abcde").is_empty()); |
| 334 | 1× | } |
| 335 | |
|
| 336 | | #[test] |
| 337 | 1× | fn under_the_cap_nothing_is_touched() { |
| 338 | 1× | let dir = tempfile::tempdir().unwrap(); |
| 339 | 1× | let old = file(dir.path(), "debug/deps/libold-1111111111111111.rlib", 64, 1_000); |
| 340 | 1× | let p = prune(dir.path(), at(T), &[], 1 << 30, 64 << 10, 60).unwrap(); |
| 341 | 1× | assert!(old.exists() && p.fits() && p.freed == 0 && p.after == p.before, "{p:?}"); |
| 342 | 1× | } |
| 343 | |
|
| 344 | | |
| 345 | | |
| 346 | | |
| 347 | | #[test] |
| 348 | 1× | fn what_this_run_did_not_use_goes_oldest_first_and_its_units_stay_whole() { |
| 349 | 1× | let dir = tempfile::tempdir().unwrap(); |
| 350 | 1× | let root = dir.path(); |
| 351 | 1× | let app = file(root, "debug/deps/app-aaaaaaaaaaaaaaaa", 256, T + 100); |
| 352 | 1× | let app_fp = file(root, "debug/.fingerprint/app-aaaaaaaaaaaaaaaa/bin-app", 1, T + 100); |
| 353 | 1× | let dep_rlib = file(root, "debug/deps/libdep-bbbbbbbbbbbbbbbb.rlib", 256, T - 5_000); |
| 354 | 1× | let dep_fp = file(root, "debug/.fingerprint/dep-bbbbbbbbbbbbbbbb/lib-dep", 1, T - 5_000); |
| 355 | 1× | std::fs::File::open(&dep_fp).unwrap().set_times(std::fs::FileTimes::new().set_accessed(at(T + 50))).unwrap(); |
| 356 | 1× | let script = file(root, "debug/build/dep-bbbbbbbbbbbbbbbb/out/gen.rs", 64, T - 5_000); |
| 357 | | |
| 358 | 1× | std::fs::hard_link(&app, root.join("debug/app")).unwrap(); |
| 359 | | |
| 360 | 1× | let oldest = file(root, "debug/deps/libdep-cccccccccccccccc.rlib", 256, T - 9_000); |
| 361 | 1× | let oldest_fp = file(root, "debug/.fingerprint/dep-cccccccccccccccc/lib-dep", 1, T - 8_999); |
| 362 | 1× | let older = file(root, "debug/deps/libdep-dddddddddddddddd.rlib", 256, T - 8_000); |
| 363 | | |
| 364 | 1× | let output = file(root, "release/tool", 128, T - 10_000); |
| 365 | 1× | let all = [&app, &app_fp, &dep_rlib, &dep_fp, &script, &oldest, &oldest_fp, &older, &output]; |
| 366 | 9× | let before: u64 = all.iter().map(|p| bytes(p)).sum(); |
| 367 | | |
| 368 | 1× | let want = before - bytes(&oldest) - bytes(&oldest_fp); |
| 369 | 1× | let size = (want * 100).div_ceil(60); |
| 370 | 1× | let p = prune(root, at(T), &["/release/tool".into()], size, before, 60).unwrap(); |
| 371 | 1× | assert!(p.fits(), "{p:?}"); |
| 372 | 1× | assert!( |
| 373 | 1× | !oldest.exists() && !root.join("debug/.fingerprint/dep-cccccccccccccccc").exists(), |
| 374 | | "the oldest unit went, its emptied directory too" |
| 375 | | ); |
| 376 | 1× | assert!(older.exists(), "and pruning stopped under the cap: {p:?}"); |
| 377 | 7× | for kept in [&app, &app_fp, &dep_rlib, &dep_fp, &script, &output, &root.join("debug/app")] { |
| 378 | 7× | assert!(kept.exists(), "{} stays", kept.display()); |
| 379 | | } |
| 380 | 1× | assert_eq!((p.files, p.freed, p.after), (2, p.before - want, want)); |
| 381 | | |
| 382 | 1× | let p = prune(root, at(T), &["release/tool".into()], 1, p.after, 60).unwrap(); |
| 383 | 1× | assert!(!older.exists() && !p.fits() && p.files == 1, "{p:?}"); |
| 384 | 5× | for kept in [&app, &dep_rlib, &dep_fp, &script, &output] { |
| 385 | 5× | assert!(kept.exists(), "{} stays", kept.display()); |
| 386 | | } |
| 387 | 1× | assert!(root.join("debug/deps").is_dir(), "a directory still holding files stays"); |
| 388 | 1× | } |
| 389 | |
|
| 390 | | #[test] |
| 391 | 1× | fn reads_are_forgotten_so_the_next_is_seen() { |
| 392 | 1× | let dir = tempfile::tempdir().unwrap(); |
| 393 | 1× | let p = file(dir.path(), "a/b", 1, 5_000); |
| 394 | 1× | std::os::unix::fs::symlink("/etc/passwd", dir.path().join("link")).unwrap(); |
| 395 | 1× | assert_eq!(forget_reads(dir.path()).unwrap(), 1); |
| 396 | 1× | let md = std::fs::metadata(&p).unwrap(); |
| 397 | 1× | assert_eq!((md.atime(), md.mtime()), (0, 5_000), "the mtime is kept"); |
| 398 | 1× | assert!(std::fs::symlink_metadata(dir.path().join("link")).unwrap().atime() > 0, "a link is not followed"); |
| 399 | 1× | } |
| 400 | | } |