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.
| 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>
|
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 |
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