From 3e016c92ee7d6cf5eded072716e29583a446f1fa Mon Sep 17 00:00:00 2001 From: muraa-p <85794781+muraa-p@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:40:57 +0800 Subject: [PATCH] Leave a pin's old program copies out of the copy (#56) A pin leaves out a program's old running copies (name.running-N.exe) when the program they copy is beside them. --- crates/engine/src/pins.rs | 60 +++++++++++++++++++++++++++++++++++++-- docs/ROADMAP.md | 2 +- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/pins.rs b/crates/engine/src/pins.rs index 93e4080a..ff7f5eba 100644 --- a/crates/engine/src/pins.rs +++ b/crates/engine/src/pins.rs @@ -230,7 +230,9 @@ impl Plan { /// Every (file, where it goes under `to`) the copy makes, in order. fn files(&self, ws: &Workspace, to: &Path) -> Result, ToolError> { let (mut own, mut dependencies) = (Vec::new(), Vec::new()); - let skip = |p: &Path| workspace::rebuilt(p) || ws.is_own_record(p) || p.file_name().and_then(|n| n.to_str()).is_some_and(|n| NEVER_COPIED.contains(&n)); + let skip = |p: &Path| { + workspace::rebuilt(p) || ws.is_own_record(p) || p.file_name().and_then(|n| n.to_str()).is_some_and(|n| NEVER_COPIED.contains(&n)) || stray_copy(p) + }; walk(&self.app, to, &skip, 0, &mut own)?; for k in &self.keep { let (from, whole) = match k { @@ -239,12 +241,19 @@ impl Plan { }; let rel = from.strip_prefix(&self.app).map_err(|_| ToolError::internal("a kept folder isn't inside the app"))?; if whole { - walk(from, &to.join(rel), &|p| p.file_name().and_then(|n| n.to_str()).is_some_and(|n| NEVER_COPIED.contains(&n)), 0, &mut dependencies)?; + walk( + from, + &to.join(rel), + &|p| p.file_name().and_then(|n| n.to_str()).is_some_and(|n| NEVER_COPIED.contains(&n)) || stray_copy(p), + 0, + &mut dependencies, + )?; } else { for e in std::fs::read_dir(from).map_err(|e| ToolError::io(from.display(), e))?.flatten() { if let Ok(m) = e.metadata() && m.is_file() && !compiler_leftover(&e.path()) + && !stray_copy(&e.path()) { own.push((e.path(), to.join(rel).join(e.file_name()), m.len())); } @@ -270,6 +279,25 @@ fn compiler_leftover(p: &Path) -> bool { kind || p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with(".cargo-")) } +/// An old copy of a program, left behind when a build replaced one that was running +/// (`snake.running-144900.exe`): a pin copies the program, not each copy of it. It only counts as +/// one when the program it copies is still beside it, so a helper that happens to be named like +/// one stays. +fn stray_copy(p: &Path) -> bool { + let Some(name) = p.file_name().and_then(|n| n.to_str()) else { return false }; + let Some((base, rest)) = name.rsplit_once(".running-") else { return false }; + let (digits, ext) = rest.split_once('.').map_or((rest, None), |(d, e)| (d, Some(e))); + if base.is_empty() || digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return false; + } + let original = match ext { + Some(e) => format!("{base}.{e}"), + None => base.to_string(), + }; + let original = p.with_file_name(original); + original.is_file() && crate::outputs::is_program(&original) +} + /// How deep a copy goes. const WALK_DEPTH: usize = 64; @@ -552,6 +580,34 @@ mod tests { assert!(!ws.root.join(PINNED).exists()); } + #[test] + fn a_pin_leaves_out_old_copies_of_its_program_but_keeps_helpers() { + let d = tempfile::tempdir().unwrap(); + let ws = built(d.path()); + let release = ws.app_dir().join("target/release"); + let before = estimate(&ws, None).unwrap(); + let program = |name: &str, body: &[u8]| { + let p = release.join(exe(name)); + std::fs::write(&p, body).unwrap(); + mark_program(&p); + }; + // What a build leaves when it replaces a program that was running, and two files that only + // look like one: a helper with nothing beside it, and a copy whose program is gone. + program("snake.running-144900", &[0u8; 4096]); + program("snake-tool.running-1", b"helper"); + program("ghost.running-7", b"gone"); + let after = estimate(&ws, None).unwrap(); + assert_eq!(after.files, before.files + 2, "the old copy isn't counted, the two others are"); + assert_eq!(after.bytes, before.bytes + 6 + 4, "and its size isn't either"); + let pin = create(&ws, PinMode::Frozen, None, None, &mut |_, _| true).unwrap(); + assert_eq!(pin.bytes, after.bytes); + let copy = pin.app_dir(&ws).join("target/release"); + assert!(copy.join(exe("snake")).is_file()); + assert!(!copy.join(exe("snake.running-144900")).exists(), "the old copy of the program is left out"); + assert!(copy.join(exe("snake-tool.running-1")).is_file(), "a helper with nothing it copies beside it stays"); + assert!(copy.join(exe("ghost.running-7")).is_file(), "a copy whose program is gone is all there is to run"); + } + #[test] fn a_light_pin_shares_media_and_survives_a_rebuild_replacing_or_deleting_them() { let d = tempfile::tempdir().unwrap(); diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c72829ae..8be6e1ff 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -829,7 +829,7 @@ to the project next. Pins are the engine's (tools), kept in the project's `pinne - [x] Pins leave out what a compiler keeps beside a program that nothing needs to run it (`.pdb` debug symbols, `.lib`, `.d`, lock files): measured on real Rust projects, that took a 291 MB pin to 49 MB, far more than sharing could -- [ ] Leave out a project's old program copies (`*.running-*.exe`, 22 MB each in one project), which +- [x] Leave out a project's old program copies (`*.running-*.exe`, 22 MB each in one project), which a pin can't tell from the program's helpers by their name alone - [ ] Say when a light pin ended up copying what it meant to share (a drive without hard links) - [ ] Pins of one project sharing with each other, so a third frozen copy of the same assets