-
Notifications
You must be signed in to change notification settings - Fork 2
Isolate test fixtures instead of serializing (replaces #56) #57
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,85 @@ | ||
| use std::{fs, path::PathBuf}; | ||
| use std::{ | ||
| fs, | ||
| path::{Path, PathBuf}, | ||
| }; | ||
| use tempfile::TempDir; | ||
|
|
||
| // | ||
| // For more information about this file's naming convention, see | ||
| // https://doc.rust-lang.org/book/ch11-03-test-organization.html | ||
| // | ||
|
|
||
| /// A private copy of a fixture app, for tests that run `pks` against it. | ||
| /// | ||
| /// Tests within a binary run on parallel threads, and running `pks` writes into | ||
| /// the fixture (`tmp/cache/packwerk/...`), while several helpers in this module | ||
| /// delete or rewrite fixture files. Sharing one on-disk copy between threads | ||
| /// therefore races: one test removes a directory another is mid-way through | ||
| /// writing into. | ||
| /// | ||
| /// Copying is cheap -- the largest fixture is 64 KB -- and buys real isolation, | ||
| /// so tests stay parallel and need no teardown. The copy is removed when the | ||
| /// `Fixture` is dropped. | ||
| /// | ||
| /// One assumption to be aware of: the copy itself is only race-free because | ||
| /// `cargo test` runs test *binaries* sequentially. Files not yet converted to | ||
| /// this helper (`check_test.rs`, `check_unused_dependencies.rs`, and others) | ||
| /// still call `teardown()`, which deletes `tests/fixtures/*/tmp/cache/packwerk` | ||
| /// across every fixture. If that ran while `copy_dir_recursive` was mid-walk of | ||
| /// the same subtree, `read_dir`/`copy` would fail with `NotFound` and the panic | ||
| /// below would fire. Cargo finishes each binary, teardowns included, before | ||
| /// starting the next, so this cannot happen today -- but a move to | ||
| /// `cargo-nextest`, which runs binaries concurrently, would expose it. Converting | ||
| /// the remaining callers off `teardown()` removes the assumption entirely. | ||
| #[allow(dead_code)] | ||
| pub struct Fixture { | ||
| // Held for its Drop impl: removing it deletes the copy. | ||
| _dir: TempDir, | ||
| root: PathBuf, | ||
| } | ||
|
|
||
| #[allow(dead_code)] | ||
| impl Fixture { | ||
| /// Copies `tests/fixtures/<name>` into a fresh temporary directory. | ||
| pub fn new(name: &str) -> Fixture { | ||
| 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| { | ||
| panic!("could not copy fixture {}: {}", source.display(), e) | ||
| }); | ||
| Fixture { _dir: dir, root } | ||
| } | ||
|
|
||
| /// The fixture root, to pass to `--project-root`. | ||
| pub fn root(&self) -> &Path { | ||
| &self.root | ||
| } | ||
|
|
||
| /// A path inside the fixture. | ||
| pub fn path(&self, relative: &str) -> PathBuf { | ||
| self.root.join(relative) | ||
| } | ||
| } | ||
|
|
||
| fn copy_dir_recursive(from: &Path, to: &Path) -> std::io::Result<()> { | ||
| fs::create_dir_all(to)?; | ||
| for entry in fs::read_dir(from)? { | ||
| let entry = entry?; | ||
| let target = to.join(entry.file_name()); | ||
| // `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() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Latent nit, not live:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 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_recursive(&entry.path(), &target)?; | ||
| } else { | ||
| fs::copy(entry.path(), &target)?; | ||
| } | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| #[allow(dead_code)] | ||
| pub fn teardown() { | ||
| glob::glob("tests/fixtures/*/tmp/cache/packwerk") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's an invisible assumption here worth a comment: this copy is only race-free because
cargo testruns test binaries sequentially.check_test.rsandcheck_unused_dependencies.rsstill runpksagainst the sharedtests/fixtures/simple_appand still call the globalteardown(), which globs and deletestests/fixtures/*/tmp/cache/packwerkacross all fixtures. If that deletion landed whilecopy_dir_recursivewas mid-walk of the same subtree,read_dir/copywould returnNotFoundand thisunwrap_or_elsewould 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 adoptscargo-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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
Fixturenow says: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.