Uh oh!
There was an error while loading. Please reload this page.
Isolate test fixtures instead of serializing (replaces #56) - #57
Conversation
Two tests fail intermittently on main: `gitignore_test::test_respect_gitignore_can_be_disabled` (2 in 20 idle, 3 in 6 under load) and `create_test::test_create_already_exists` (about 1 in 10). Both come from the same thing: tests run `pks` against the shared fixtures in `tests/fixtures/`, `pks` writes into the project root it is given (`tmp/cache/packwerk/...`), and the cleanup helpers here mutate global state -- `teardown()` deletes the cache of *every* fixture, `delete_foobar*()` removes whole pack directories. Tests in a binary run on parallel threads, so those cleanups delete state a sibling test is still using. `pks` writes a cache entry as `create_dir_all(parent)` then `File::create`, and losing the parent between those two calls is the EINVAL in the gitignore failure. Adds `common::Fixture`, which copies a fixture into a temp directory and removes it on drop. Converting the affected tests to it removes the shared state rather than serializing access to it, so the tests stay parallel and need no cleanup calls at all. Chosen over `#[serial]` because it fixes the cause instead of the symptom: with isolation there is no shared state left to race over, so a future test cannot reintroduce the bug by forgetting an attribute. It is also faster (0.72s vs 0.83s for these two files) since the tests keep running concurrently, and it stops the suite leaving modified fixtures in the working tree -- `git status` after a run is now clean, where before it routinely showed a rewritten package.yml. `test_update_respects_gitignore` already hand-rolled this exact pattern with a local `copy_dir_all`; that is now folded into the shared helper and the duplicate deleted. One `#[serial]` remains, and is correct: `test_respects_global_gitignore` mutates `git config --global`, which is machine-wide and cannot be isolated by copying files. It is now also given an isolated fixture so it stops writing a scratch file into the repo tree. Verified: 25 consecutive runs of each file green, 5 consecutive full-suite runs at 258 passing / 0 failing, and no fixture left dirty afterwards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dduugg
left a comment
There was a problem hiding this comment.
Approving. I agree with the approach — isolating the state beats serializing access to it, and the reasoning in the description for preferring this over #56 matches what I found in the code. One factual correction to the Verification table, detailed below.
Verified
Fixturestores theTempDiritself in_dir, not a.path()snapshot, so the temp dir outlives every use androot()can never point at a deleted path. This is the bug I most expected to find here; it isn't present..gitignorefiles really are copied.copy_dir_recursiveuses plainfs::read_dir, which doesn't skip dotfiles. Worth stating explicitly because the failure mode — gitignore tests passing vacuously against a copy with no.gitignorein it — would have been invisible and would have made the whole suite worthless.- Moving fixtures to a tmpdir doesn't change what the git tests exercise:
build_gitignore_matcheronly reads<given_root>/.gitignoreand<given_root>/.git/info/excludedirectly, with no parent-directory walk to find an enclosing.git, and no fixture ships its own.git. Globalcore.excludesFileis machine-wide and location-independent. So behavior is identical in-tree vs copied. FixtureisSend/Sync, andTempDir::new()yields a unique path per call, so two concurrent copies of the same fixture name can't collide.- The removed
copy_dir_alland the newcopy_dir_recursiveare logically identical, so folding it into the shared helper is a clean consolidation. - The
create_dir_all/File::creatediagnosis is correct —src/packs/caching/per_file_cache.rs:58-67, which explains theEINVAL. - Both target tests are solid now:
test_create_already_existsandtest_respect_gitignore_can_be_disabled, 20/20 each. - Keeping
#[serial]ontest_respects_global_gitignoreis the right call —git config --globalis machine-wide state that copying files cannot isolate.
Correction: the "git status tests/fixtures/ after 5 full runs: clean" row doesn't hold.
Reproduced from a clean checkout of this branch: running just cargo test --test check_unused_dependencies leaves tests/fixtures/app_with_unnecessary_dependencies/packs/foo/package.yml modified, every time.
The mechanism is worth stating precisely, because it's the opposite of what you might expect: common::set_up_fixtures() writes content byte-identical to what's committed (I diffed it), so set_up_fixtures is the restore, not the mutation. The dirt comes from test_auto_correct_unnecessary_dependencies, which runs pks -a and rewrites that file to its corrected form with nothing restoring it afterward — set_up_fixtures() only runs at the start of each case, inside assert_auto_correct_unused_dependencies. So whether the tree ends clean depends on which binary happens to run last.
This is pre-existing and not caused by this PR — #52's description notes the same file. But it's the exact file named in your "Still available as follow-up" section, and the Verification table asserts the problem is now resolved, which undersells what's left. Suggest adjusting that row to scope the claim to the fixtures this PR actually isolates.
Two inline nits, both latent rather than live. Also, since teardown() and its glob survive this PR, create_test.rs retains one call, and check_test.rs still has 20+ shared-fixture sites, this narrows the flake surface rather than closing it — which your follow-up section already says. Worth landing this first regardless: it makes CI trustworthy for the #52/#53/#54 stack, and I confirmed it merges cleanly with #52 in either order (that PR edits teardown() below your insertion, and both changes survive).
| for entry in fs::read_dir(from)? { | ||
| let entry = entry?; | ||
| let target = to.join(entry.file_name()); | ||
| if entry.file_type()?.is_dir() { |
There was a problem hiding this comment.
Latent nit, not live: entry.file_type() does not follow symlinks, so a symlink-to-directory would fail is_dir(), fall into the fs::copy branch, and error on a directory target.
find tests/fixtures -type l returns nothing today, so no fixture exercises this. Only worth handling if a fixture ever needs a symlink — flagging it so the failure is recognizable rather than mysterious if that happens.
There was a problem hiding this comment.
Added in a5a584e:
// `file_type()` does not follow symlinks, so a symlink to a directory// would take the `fs::copy` branch below and fail with a directory// target. No fixture contains a symlink today (`find tests/fixtures// -type l` is empty); handle it here if one ever needs to.if entry.file_type()?.is_dir(){Confirmed find tests/fixtures -type l is empty independently.
I deliberately documented rather than handled it. Following symlinks would mean choosing between copying the target — which silently changes what the fixture is, since a fixture using a symlink probably does so on purpose — and preserving the link, which would point outside the temp dir and defeat the isolation this PR exists to provide. Neither is obviously right without a fixture that actually needs one, so guessing now would bake in the wrong answer. The comment makes the failure legible when someone has the real requirement in front of them.
Same behavior as the pre-existing copy_dir_all this replaces, so it isn't a regression — just a newly-documented edge.
| let dir = TempDir::new().expect("could not create temp dir"); | ||
| let root = dir.path().join(name); | ||
| let source = Path::new("tests/fixtures").join(name); | ||
| copy_dir_recursive(&source, &root).unwrap_or_else(|e| { |
There was a problem hiding this comment.
There's an invisible assumption here worth a comment: this copy is only race-free because cargo test runs test binaries sequentially.
check_test.rs and check_unused_dependencies.rs still run pks against the shared tests/fixtures/simple_app and still call the global teardown(), which globs and deletes tests/fixtures/*/tmp/cache/packwerk across all fixtures. If that deletion landed while copy_dir_recursive was mid-walk of the same subtree, read_dir/copy would return NotFound and this unwrap_or_else would panic — a new failure mode introduced by copying.
Not exploitable today: cargo finishes each binary (and all its teardown() calls) before starting the next, and I stress-tested it by forcing all four binaries to run as concurrent OS processes, 15 iterations, with no copy panics. The risk appears only if the repo adopts cargo-nextest, which does run binaries concurrently.
A one-line comment noting the dependency would keep a future nextest migration from rediscovering this the hard way.
There was a problem hiding this comment.
Added in a5a584e — this is the more valuable of the two nits, because it's a failure mode introduced by this PR rather than one it inherits, and it would be invisible until someone migrates.
The doc comment on Fixture now says:
One assumption to be aware of: the copy itself is only race-free because
cargo testruns test binaries sequentially. Files not yet converted to this helper (check_test.rs,check_unused_dependencies.rs, and others) still callteardown(), which deletestests/fixtures/*/tmp/cache/packwerkacross every fixture. If that ran whilecopy_dir_recursivewas mid-walk of the same subtree,read_dir/copywould fail withNotFoundand the panic below would fire. Cargo finishes each binary, teardowns included, before starting the next, so this cannot happen today — but a move tocargo-nextest, which runs binaries concurrently, would expose it. Converting the remaining callers offteardown()removes the assumption entirely.
I kept your last sentence as the closing line deliberately: the comment should point at the fix, not just describe the hazard, so whoever hits it knows the exit rather than reaching for a retry loop.
Also worth recording that you stress-tested it by forcing all four binaries to run as concurrent OS processes for 15 iterations with no copy panics. That's a stronger negative result than "cargo doesn't do this today" — it says the window is narrow even when you deliberately open it.
Both raised in review, both latent rather than live, both worth recording so the failure is recognizable if it ever fires. The copy is only race-free because `cargo test` runs test binaries sequentially. Files not yet converted to `Fixture` still call the global `teardown()`, which deletes `tests/fixtures/*/tmp/cache/packwerk` across every fixture; if that ran during `copy_dir_recursive`, the copy would panic with NotFound. Cargo finishes each binary before starting the next, so it cannot happen today, but `cargo-nextest` runs binaries concurrently and would expose it. `entry.file_type()` does not follow symlinks, so a symlink-to-directory would take the `fs::copy` branch and fail on a directory target. `find tests/fixtures -type l` is empty, so no fixture exercises this. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
perryqh
commented
Aug 20, 2026
Thanks — the correction is right and I reproduced it 3/3. Description updated. On the dirty tree. Your mechanism is exactly right: Worth naming why I got it wrong rather than just fixing the row: my verification loops ran That row is now scoped to the fixtures this PR actually isolates, and the follow-up section carries the real remaining numbers — 13 files still call Both inline nits are now comments in One small correction back. Does not change your point that Also appreciate you checking the two things that would have made this PR worthless — that |
Uh oh!
There was an error while loading. Please reload this page.
One conflict, in `update`'s summary line. rubyatscale#52 bumped the toolchain to 1.97.1 and the newer clippy removed the needless borrow in `&strict_violations.len()`; this branch had renamed that binding to `unlisted_strict_violations` when it added the recorded filter. Resolved as both: the rename kept, the borrow dropped. Everything else merged clean, including `tests/common/mod.rs`, where rubyatscale#57 adds `common::Fixture` next to this branch's `RoundTripFixture`. Worth flagging that they now solve the same problem two ways: rubyatscale#57 copies a fixture to a temp dir and drops it, while `RoundTripFixture` restores the shared fixture in place. rubyatscale#57 converts `create_test.rs` and `gitignore_test.rs` only, so `update_test.rs` still uses the older mechanism. Happy to fold the three round-trip tests onto `common::Fixture` if that is preferred, in this PR or a follow-up. Verified on the merged tree with the 1.97.1 toolchain the merge brings in: `cargo test --no-fail-fast` 265 passed 0 failed, clippy with `-Dwarnings` clean, `cargo fmt --check` clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`process_files_with_cache` is the largest phase of `pks check`, and half of it is work we can avoid: for every file we open it, read it in full, and MD5 it, purely to compare a digest against the cache entry. Record (mtime_ns, len) alongside the digest and settle the common case -- nothing changed since the last run -- with one `stat`. The digest remains the authority: if no stat is recorded, or the stat moved, we fall back to reading and hashing exactly as before. An entry whose contents match but whose stat moved (a git checkout, a `touch`) is repaired in place so the next run takes the fast path. MEASURED on a 51,513-file application, A/B against main in one hyperfine run: main 4.945s +/- 0.118 this branch 3.450s +/- 0.117 1.43x faster user time 7.534s -> 6.877s system time 12.927s -> 8.495s (-34%) An earlier batch on a busier machine measured the same pair at 1.30x. Both are valid single-batch comparisons; the ratio moves with load because the phases this does not touch are a larger share when the machine is quiet. The system-time drop is the stable signal, and it is the mechanism: this phase is syscall-bound, not CPU-bound. An earlier attempt to speed the same phase up by parsing JSON faster (from_reader -> from_slice) changed nothing measurable, which is what pointed at the syscalls. ## Coarse filesystems are detected, not assumed away Trusting (mtime, len) is only sound where the filesystem timestamps finely enough to notice a write. At one-second granularity -- some Docker bind mounts on macOS, NFS, SMB, FAT -- a same-length edit inside the same second keeps both fields, and a stat-only check would serve the stale entry. Rather than probe or assume a platform, `SourceStat::of` reads the value it already has: a non-zero sub-second component proves the filesystem tracks sub-second time, so an edit at any other instant would have moved the mtime. A zero component means it cannot tell us, so the stat is discarded and the digest carries the entry. This needed no new branches at the call sites -- `None` already meant "no usable stat" -- and both ways of being wrong fail safe: - Coarse filesystem: nothing is trusted, the fast path never engages. Correct, just not faster. - Fine filesystem, mtime landing exactly on a second boundary: a 1-in-10^9 coincidence costing one extra hash. Measured across 20,003 files of a real Rails application: zero occurrences. Cost of the check itself: 1.00x +/- 0.02 against the same branch without it. It narrows rather than closes the window, and the type's docs say so. A millisecond-granularity filesystem is trusted, so two same-length writes inside one millisecond would still be missed -- six orders of magnitude tighter, and needing machine-speed edits to reach. Also documented: mtimes that are copied rather than set by writing (rsync -t, tar -p, cp -p) can carry a timestamp from elsewhere; every mtime-driven cache shares that hole, which is why they all document `touch` as the way to force a rebuild. ## packwerk compatibility Verified against packwerk 3.3.0, and the concern turned out to be misplaced: - Its `Cache::CacheContents.deserialize` uses plain hash access and never enumerates keys, so an unknown key is invisible to it. Ran its logic against a packwerk-format entry carrying `source_stat`: reads fine. - The tools do not share a directory. packwerk reads `tmp/cache/packwerk/<md5>`; pks writes `tmp/cache/packwerk/zeitwerk/<md5>`. - The formats were never interchangeable. Feeding packwerk what pks writes today raises `NoMethodError: undefined method 'map' for nil`. That predates this change, so `test_compatible_with_packwerk` does not test what its name claims; it round-trips pks's own format. Left alone, but it is not a guarantee. Regardless, `source_stat` is `#[serde(default, skip_serializing_if)]`, so an entry without one still deserializes and is still honored via the digest. ## Failure modes closed by construction - `EmptyCacheEntry` holds `Option<String>` rather than an empty string meaning "not computed", private behind `digest()`. `write` errors instead of persisting a placeholder, which would have produced an entry that never matches -- making that file permanently uncacheable and silently slow. - The in-place repair warns on failure rather than discarding the error. The result stays correct either way, but a persistent failure (unwritable cache dir, full disk) would otherwise leave every run re-hashing with no clue why. - That repair only fires when there is a stat worth recording. Without the guard, a filesystem yielding `None` every run would never match and would rewrite the entire cache every time. ## Tests tests/cache_stat_fastpath_test.rs, ten cases. Note that before this change *no test in the repo exercised a warm cache at all* -- every fixture ships `cache: false` -- so these paths were untested rather than under-tested. The fast path: stats are recorded; warm output matches cold; an edit invalidates; a *same-length* edit invalidates (the case a length-only check would miss); a whole-second mtime is not trusted. Fallback and repair: a stat-less packwerk-style entry is honored then upgraded; a stale stat with a matching digest is repaired in place; a malformed `source_stat` (five shapes) degrades to the digest without panicking. Other commands: `pks update` on a warm cache -- the highest-consequence path, since update *writes* package_todo.yml and a stale entry persists a wrong answer rather than printing one -- and the experimental parser, which uses a different cache subdirectory but shares this implementation. Each was verified to fail rather than assumed to pass: injecting a bug that makes the cache always hit fails 7 of the 10, and the granularity and repair guards were separately confirmed to fail with their own checks removed. Uses `common::Fixture` from #57 rather than a local copy helper. Verified: `check` and `check --no-cache` produce identical output on the 51k-file application, as do this branch and main. Across the 30 fixture apps with a packwerk.yml, 29 are byte-identical; the 30th is app_with_monkey_patches, which trips the pre-existing nondeterministic duplicate-constant panic in both binaries. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
No conflicts. This branch had drifted nine commits behind and still pinned Rust 1.92.0, while `main` moved to 1.97.1 in rubyatscale#52 and reworked all four workflow files in rubyatscale#51, rubyatscale#55 and rubyatscale#57. So a CI run on the old tip would have tested this against a toolchain and a workflow set that no longer exist, which is worth avoiding given no run has ever been approved here. `recorded_key()` and the two comparison sites are untouched by the merge. The only changes reaching this branch's own code are main's clippy fixes in the `build_stale_violations` error paths, which arrived cleanly. Verified on the merged tree with the 1.97.1 toolchain the merge brings in: `cargo test --no-fail-fast` 258 passed 0 failed, `cargo clippy --all-targets --all-features -- -Dwarnings` clean, `cargo fmt --check` clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces #56, which fixed the same two flakes with
#[serial]. Same failures, better mechanism — see the comparison below.Two tests fail intermittently on
maintoday:gitignore_test::test_respect_gitignore_can_be_disabledcreate_test::test_create_already_existsCause
Tests run
pksagainst the shared fixtures intests/fixtures/,pkswrites into whatever project root it is given (tmp/cache/packwerk/...), and the cleanup helpers intests/common/mod.rsmutate global state:teardown()globstests/fixtures/*/tmp/cache/packwerkand deletes the cache of every fixture, not the one the caller useddelete_foobar()/delete_foobaz()/delete_foobar_app_with_custom_readme()remove whole pack directoriesTests within a binary run on parallel threads, so those cleanups delete state a sibling test is still using.
The exact window for the gitignore failure:
pkswrites a cache entry ascreate_dir_all(parent)thenFile::create(src/packs/caching/per_file_cache.rs:58-67). Losing the parent between those two calls is why it surfaces asEINVALrather than theENOENTyou would expect:Fix
Adds
common::Fixture, which copies a fixture into a temp directory and deletes it on drop. Affected tests get their own copy, so there is no shared state to race over and no cleanup calls at all.test_update_respects_gitignorealready hand-rolled this exact pattern with a localcopy_dir_all. That is now folded into the shared helper and the duplicate deleted — so this consolidates an approach the file already used rather than introducing a new one.Why this over
#[serial](#56)#[serial](#56)#[serial]attributes neededForgetting an attribute is silent.
#[serial]requires every current and future test touching these fixtures to remember it, with no error if it does not — which is howcreate_testwas broken whilegitignore_testhad one test correctly marked. Isolation removes the hazard instead of documenting it.Copying is cheap — the largest fixture involved is 64 KB.
One
#[serial]remains, and it is correcttest_respects_global_gitignoremutatesgit config --global. That is machine-wide state which copying files cannot isolate, so serializing is the right tool there specifically. It predates this PR. It now also gets an isolated fixture so it stops writing a scratch file into the repo tree.Verification
gitignore_test, 25 consecutive runscreate_test, 25 consecutive runscargo fmt --all -- --checkandcargo clippy --all-targets --all-featuresclean.Note
Correction. An earlier version of this description claimed
git status tests/fixtures/was clean after a full run. That was wrong, and thanks to @dduugg for catching it.cargo test --test check_unused_dependenciesleavesapp_with_unnecessary_dependencies/packs/foo/package.ymlmodified every time — reproduced 3/3 here.My own verification had masked it: my loops ran
git checkout -- tests/fixtures/between iterations, so I cleaned up the evidence and then reported the result as clean.The mechanism is the opposite of what you would guess.
set_up_fixtures()writes content byte-identical to what is committed, so it is the restore, not the mutation. The dirt comes fromtest_auto_correct_unnecessary_dependenciesrunningpks -a, which rewrites the file to its corrected form (drops- packs/baz, reorders keys) with nothing restoring it afterwards —set_up_fixtures()only runs at the start of each case. Whether the tree ends up clean depends on which binary happens to run last.Pre-existing, in a file this PR does not convert. The row above is now scoped to what this PR actually fixes.
The flakes were costing more than noise
cargo teststops at the first failing target, so a redgitignore_testtruncated the run: 240 tests attempted instead of 258 — roughly 18 tests in later binaries silently never executed. A flaky test early in the sequence was quietly reducing coverage on exactly the runs where you would most want it.This narrows the flake surface, it does not close it
Stated plainly, since the numbers make the remaining scope clear.
teardown()and its glob survive, and 13 files still call it —check_test.rsalone has 24 call sites:teardown()callscheck_test.rsfolder_privacy_test.rsadd_dependency_test.rs,update_test.rs,layer_violations_test.rs,validate_test.rs,visibility_test.rs#[serial]check_unused_dependencies.rsset_up_fixtures()I ran
check_testandcheck_unused_dependencies12× each and they stayed green, so those read as latent rather than live.common::Fixtureis the migration path: converting those files removesteardown()and thedelete_*helpers entirely, and would also fix the dirty-tree problem above. Out of scope here — this PR fixes the two failures that actually reproduce, so CI is trustworthy for the #52/#53/#54 stack.Latent assumptions, now documented in the code
Both raised in review, neither reachable today:
cargo testrunning binaries sequentially. Unconverted files still call the globalteardown(); if that ran mid-copy,copy_dir_recursivewould panic withNotFound. Cargo finishes each binary before starting the next, so it cannot happen — butcargo-nextestruns binaries concurrently and would expose it.entry.file_type()does not follow symlinks, so a symlink-to-directory would take thefs::copybranch and fail.find tests/fixtures -type lis empty.🤖 Generated with Claude Code