From e8739b15b411867d1abd067a41385e126cf7dbd2 Mon Sep 17 00:00:00 2001 From: jdalton Date: Fri, 31 Jul 2026 21:30:23 -0400 Subject: [PATCH 1/2] test(compile): lock in auto-opt freshness on runtime/stdlib source edits The auto-optimize build stamp is keyed on a content fingerprint of every source tree that lands in the runtime/stdlib archives (added in #5930 to close #5892), so a source edit invalidates a cached target/perry-auto- archive even when mtimes lie (git checkout, cp -p, CI cache restore). That guarantee had direct test coverage only for the routed perry-ext-* crates, not for perry-runtime / perry-stdlib themselves -- the crates a runtime dev edits most, and the ones behind the GC-iteration "perry compile silently reused a stale libperry_runtime.a" trap. Add two regression tests: - source_fingerprint_tracks_runtime_and_stdlib_content_not_mtimes: a content edit to perry-runtime or perry-stdlib rotates the fingerprint, while rewriting identical bytes does not (keeps the no-edit rebuild a fast no-op). - runtime_source_edit_rotates_build_stamp_and_fails_freshness: ties it to the gate the driver consults -- a runtime edit rotates the build stamp so archives stamped for the pre-edit source fail auto_optimized_archives_are_fresh, no manual rm needed. --- ...-auto-opt-freshness-runtime-source-test.md | 14 +++ .../commands/compile/optimized_libs/tests.rs | 105 ++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 changelog.d/7155-auto-opt-freshness-runtime-source-test.md diff --git a/changelog.d/7155-auto-opt-freshness-runtime-source-test.md b/changelog.d/7155-auto-opt-freshness-runtime-source-test.md new file mode 100644 index 0000000000..5406ef203d --- /dev/null +++ b/changelog.d/7155-auto-opt-freshness-runtime-source-test.md @@ -0,0 +1,14 @@ +### Fixed + +- **Locked in auto-optimize freshness on runtime/stdlib source edits (#7155).** + The auto-optimize build stamp is keyed on a content fingerprint of every source + tree that lands in the runtime/stdlib archives (#5930), so editing + `crates/perry-runtime` / `crates/perry-stdlib` invalidates a cached + `target/perry-auto-` archive and forces a rebuild even when mtimes lie + (`git checkout`, `cp -p`, CI cache restore) — no manual + `rm libperry_runtime.a`. That guarantee had direct test coverage only for the + routed `perry-ext-*` crates; this adds regression tests for the runtime/stdlib + crates themselves — the ones behind the GC-iteration "compile silently reused a + stale runtime" trap — asserting a content edit rotates both the source + fingerprint and the build stamp while an identical-bytes rewrite stays a fast + no-op. diff --git a/crates/perry/src/commands/compile/optimized_libs/tests.rs b/crates/perry/src/commands/compile/optimized_libs/tests.rs index a380bc7775..cb1487c033 100644 --- a/crates/perry/src/commands/compile/optimized_libs/tests.rs +++ b/crates/perry/src/commands/compile/optimized_libs/tests.rs @@ -290,6 +290,111 @@ fn source_fingerprint_follows_workspace_dep_closure() { ); } +/// The GC-iteration "stale runtime" trap: perry-runtime / perry-stdlib are the +/// crates a runtime dev edits most, yet the pre-existing fingerprint coverage +/// only exercised the routed ext crates. A CONTENT edit to a runtime/stdlib +/// source file must rotate the fingerprint even when the file's mtime does NOT +/// advance (a `git checkout`, `cp -p`, or cache restore can hand back a fresh +/// checkout whose sources look "older" than a cached archive) — the #5892/#5930 +/// content fingerprint is exactly what makes that safe, where the mtime gate +/// alone is blind. Rewriting identical bytes must NOT rotate it, so the common +/// no-edit rebuild stays a fast cache hit. +#[test] +fn source_fingerprint_tracks_runtime_and_stdlib_content_not_mtimes() { + let dir = tempfile::tempdir().expect("tempdir"); + minimal_auto_workspace(dir.path()); + + let fp0 = auto_optimized_source_fingerprint(dir.path(), &[]); + + // mtime-only churn (identical bytes, later write time) must not rotate the + // key — this is the no-edit fast path the freshness gate relies on. + std::thread::sleep(std::time::Duration::from_millis(10)); + write_file( + &dir.path().join("crates/perry-runtime/src/lib.rs"), + b"pub fn rt() {}\n", + ); + assert_eq!( + fp0, + auto_optimized_source_fingerprint(dir.path(), &[]), + "rewriting identical runtime bytes must not rotate the fingerprint" + ); + + // A content edit to a perry-runtime source file must rotate it. + write_file( + &dir.path().join("crates/perry-runtime/src/gc.rs"), + b"pub fn collect() {}\n", + ); + let fp_rt = auto_optimized_source_fingerprint(dir.path(), &[]); + assert_ne!( + fp0, fp_rt, + "a perry-runtime source edit must rotate the fingerprint" + ); + + // Same guarantee for perry-stdlib. + write_file( + &dir.path().join("crates/perry-stdlib/src/lib.rs"), + b"pub fn stdlib_changed() {}\n", + ); + let fp_std = auto_optimized_source_fingerprint(dir.path(), &[]); + assert_ne!( + fp_rt, fp_std, + "a perry-stdlib source edit must rotate the fingerprint" + ); +} + +/// Ties the runtime-source fingerprint to the freshness gate the compile driver +/// actually consults: a content edit to perry-runtime rotates the build stamp, +/// so a `target/perry-auto-` dir stamped for the OLD source can never pass +/// `auto_optimized_archives_are_fresh` — no manual `rm libperry_runtime.a`. The +/// stamp is compared before any mtime probe, so this holds even when the edited +/// file's mtime never advanced past the cached archive. +#[test] +fn runtime_source_edit_rotates_build_stamp_and_fails_freshness() { + let dir = tempfile::tempdir().expect("tempdir"); + minimal_auto_workspace(dir.path()); + + let fp_before = auto_optimized_source_fingerprint(dir.path(), &[]); + let stamp_before = auto_optimized_build_stamp("key", None, &[], &[], &fp_before); + + // Plant archives + the stamp recorded for the OLD source, mtimes newer than + // the sources so the mtime half of the gate would (wrongly) call them fresh. + let runtime = dir + .path() + .join("target/perry-auto/release/libperry_runtime.a"); + let stdlib = dir + .path() + .join("target/perry-auto/release/libperry_stdlib.a"); + let stamp_path = dir.path().join("target/perry-auto/.perry-auto-build.stamp"); + std::thread::sleep(std::time::Duration::from_millis(10)); + write_file(&runtime, b"!\n"); + write_file(&stdlib, b"!\n"); + write_file(&stamp_path, stamp_before.as_bytes()); + + // Now a content edit to the runtime. Recompute the stamp the driver would + // expect and confirm the gate rejects the archives stamped for the old one. + write_file( + &dir.path().join("crates/perry-runtime/src/gc.rs"), + b"pub fn collect_v2() {}\n", + ); + let fp_after = auto_optimized_source_fingerprint(dir.path(), &[]); + let stamp_after = auto_optimized_build_stamp("key", None, &[], &[], &fp_after); + assert_ne!( + stamp_before, stamp_after, + "a runtime source edit must rotate the build stamp" + ); + assert!( + !auto_optimized_archives_are_fresh( + dir.path(), + &runtime, + &stdlib, + &[], + &stamp_path, + &stamp_after, + ), + "archives stamped for the pre-edit runtime source must not pass the freshness gate" + ); +} + /// Closes #507. The well-known flip's "shared tokio" allowlist /// must match the set of perry-ext-* crates whose own /// `Cargo.toml` pulls tokio. If a new wrapper is added that uses From d6ce397f2481cb6dc4ba8191bf1493ab15d1c2a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 1 Aug 2026 11:05:32 +0200 Subject: [PATCH 2/2] test(compile): make the new auto-opt freshness assertions able to fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both tests added by this PR passed with their subject sabotaged, which is the "a gate must assert its subject was live" trap CLAUDE.md documents. Measured before this commit: - Deleting the build-stamp comparison in `auto_optimized_archives_are_fresh` (`Ok(stamp) if stamp == expected_build_stamp` -> `Ok(_)`) left `runtime_source_edit_rotates_build_stamp_and_fails_freshness` GREEN. The test planted the archives BEFORE the source edit, so the edited file's own mtime was newer than the archives and the mtime half of the gate rejected them on its own — the stamp never had to be consulted. - Making the source fingerprint content-blind (hash the path, skip `fs::read`) left the same test GREEN, because its "content edit" created a NEW file (`crates/perry-runtime/src/gc.rs`) and a path-set-only hash still rotates when a path is added. Two surgical changes, no new assertions: - Both tests now edit an EXISTING runtime source file in place (`crates/perry-runtime/src/lib.rs`) instead of adding `gc.rs`, so the assertion rides on the file-content hash, not on the path set. - `runtime_source_edit_rotates_build_stamp_and_fails_freshness` plants the archives + stamp AFTER the edit, so every source is older than them and the mtime half votes "fresh". The stamp mismatch is then the only thing that can answer "stale" — which is also the real scenario the content fingerprint exists for (git checkout / cp -p / CI cache restore hand back sources whose mtimes never advance past a cached archive). Verified red-then-green: with either sabotage applied both tests now FAIL; with the tree clean `cargo test --bins -p perry optimized_libs` is 28/28. --- .../commands/compile/optimized_libs/tests.rs | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/crates/perry/src/commands/compile/optimized_libs/tests.rs b/crates/perry/src/commands/compile/optimized_libs/tests.rs index cb1487c033..1b48783100 100644 --- a/crates/perry/src/commands/compile/optimized_libs/tests.rs +++ b/crates/perry/src/commands/compile/optimized_libs/tests.rs @@ -319,10 +319,15 @@ fn source_fingerprint_tracks_runtime_and_stdlib_content_not_mtimes() { "rewriting identical runtime bytes must not rotate the fingerprint" ); - // A content edit to a perry-runtime source file must rotate it. + // A content edit to an EXISTING perry-runtime source file must rotate it. + // Editing in place (rather than adding a new file) is what makes this + // assertion depend on the file CONTENT hash: a fingerprint that hashed only + // the path set would still rotate on an added file, and the test could not + // fail. See `runtime_source_edit_rotates_build_stamp_and_fails_freshness` + // for the same reasoning applied to the freshness gate. write_file( - &dir.path().join("crates/perry-runtime/src/gc.rs"), - b"pub fn collect() {}\n", + &dir.path().join("crates/perry-runtime/src/lib.rs"), + b"pub fn rt_changed() {}\n", ); let fp_rt = auto_optimized_source_fingerprint(dir.path(), &[]); assert_ne!( @@ -345,9 +350,18 @@ fn source_fingerprint_tracks_runtime_and_stdlib_content_not_mtimes() { /// Ties the runtime-source fingerprint to the freshness gate the compile driver /// actually consults: a content edit to perry-runtime rotates the build stamp, /// so a `target/perry-auto-` dir stamped for the OLD source can never pass -/// `auto_optimized_archives_are_fresh` — no manual `rm libperry_runtime.a`. The -/// stamp is compared before any mtime probe, so this holds even when the edited -/// file's mtime never advanced past the cached archive. +/// `auto_optimized_archives_are_fresh` — no manual `rm libperry_runtime.a`. +/// +/// The stamp gate is the ONLY thing this test lets answer "stale": the archives +/// are planted *after* the source edit, so every source is older than them and +/// the mtime half of `auto_optimized_archives_are_fresh` votes "fresh". That +/// ordering is deliberate — plant them first and the edit's own mtime makes the +/// gate reject on the mtime path alone, so the assertion would still pass with +/// the stamp comparison deleted and the test could never fail (the repo's +/// "a gate must assert its subject was live" rule). It is also the real-world +/// case the content fingerprint exists for: a `git checkout` / `cp -p` / CI +/// cache restore hands back sources whose mtimes never advance past a cached +/// archive. #[test] fn runtime_source_edit_rotates_build_stamp_and_fails_freshness() { let dir = tempfile::tempdir().expect("tempdir"); @@ -356,8 +370,16 @@ fn runtime_source_edit_rotates_build_stamp_and_fails_freshness() { let fp_before = auto_optimized_source_fingerprint(dir.path(), &[]); let stamp_before = auto_optimized_build_stamp("key", None, &[], &[], &fp_before); - // Plant archives + the stamp recorded for the OLD source, mtimes newer than - // the sources so the mtime half of the gate would (wrongly) call them fresh. + // Content edit to an EXISTING runtime source file (in place, so the + // assertion rides on the content hash rather than on the path set). + write_file( + &dir.path().join("crates/perry-runtime/src/lib.rs"), + b"pub fn rt_v2() {}\n", + ); + + // Only now plant the archives + the stamp recorded for the PRE-edit source, + // so their mtimes are newer than every source and the mtime half of the gate + // says "fresh". Anything but the stamp mismatch would let this pass. let runtime = dir .path() .join("target/perry-auto/release/libperry_runtime.a"); @@ -370,12 +392,6 @@ fn runtime_source_edit_rotates_build_stamp_and_fails_freshness() { write_file(&stdlib, b"!\n"); write_file(&stamp_path, stamp_before.as_bytes()); - // Now a content edit to the runtime. Recompute the stamp the driver would - // expect and confirm the gate rejects the archives stamped for the old one. - write_file( - &dir.path().join("crates/perry-runtime/src/gc.rs"), - b"pub fn collect_v2() {}\n", - ); let fp_after = auto_optimized_source_fingerprint(dir.path(), &[]); let stamp_after = auto_optimized_build_stamp("key", None, &[], &[], &fp_after); assert_ne!(