Wave 7: Phase 2 features - sync --all, external refs, cross-alias discovery, CI/CD, reliability tests (bd-1ky, bd-1bp, bd-1rk, bd-1lj, bd-gvr, bd-1x5)

- Sync --all with async concurrency, per-host throttling, failure budgets, resumable execution
- External ref bundling at fetch time with origin tracking
- Cross-alias discovery (--all-aliases) for list and search commands
- CI/CD pipeline (.gitlab-ci.yml), cargo-deny config, Dockerfile, install script
- Reliability test suite: crash consistency (8 tests), lock contention (3 tests), property-based (4 tests)
- Criterion performance benchmarks (5 benchmarks)
- Bug fix: doctor --fix now repairs missing index.json when raw.json exists
- Bug fix: shared $ref references no longer incorrectly flagged as circular (refs.rs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-12 15:29:31 -05:00
parent 398311ca4c
commit 4ac8659ebd
20 changed files with 3430 additions and 68 deletions

View File

@@ -0,0 +1,170 @@
//! Lock contention tests for cache write safety.
//!
//! Verifies that concurrent access to the same alias doesn't cause corruption
//! or panics. Uses multiple threads (not processes) for simplicity.
#![allow(deprecated)] // assert_cmd::Command::cargo_bin deprecation
mod helpers;
use std::sync::{Arc, Barrier};
use std::thread;
/// Multiple threads fetching the same alias concurrently should all succeed
/// or receive CACHE_LOCKED errors — never corruption.
///
/// After all threads complete, the final cache state should be valid.
#[test]
fn test_concurrent_fetch_no_corruption() {
let env = helpers::TestEnv::new();
let fixture_path = helpers::fixture_path("petstore.json");
let fixture_str = fixture_path.to_str().expect("fixture path not UTF-8");
// First, do an initial fetch so the alias exists
helpers::run_cmd(&env, &["fetch", fixture_str, "--alias", "petstore"]).success();
let thread_count = 8;
let barrier = Arc::new(Barrier::new(thread_count));
let home_dir = env.home_dir.clone();
let fixture = fixture_str.to_string();
let handles: Vec<_> = (0..thread_count)
.map(|_| {
let barrier = Arc::clone(&barrier);
let home = home_dir.clone();
let fix = fixture.clone();
thread::spawn(move || {
// Wait for all threads to be ready
barrier.wait();
// All threads try to re-fetch the same alias with --force
let output = assert_cmd::Command::cargo_bin("swagger-cli")
.expect("binary not found")
.env("SWAGGER_CLI_HOME", &home)
.args(["fetch", &fix, "--alias", "petstore", "--force"])
.output()
.expect("failed to execute");
output.status.code().unwrap_or(-1)
})
})
.collect();
let exit_codes: Vec<i32> = handles.into_iter().map(|h| h.join().unwrap()).collect();
// All should either succeed (0) or get CACHE_LOCKED (exit code 9)
for (i, code) in exit_codes.iter().enumerate() {
assert!(
*code == 0 || *code == 9,
"thread {i} exited with unexpected code {code} (expected 0 or 9)"
);
}
// At least one should succeed
assert!(
exit_codes.contains(&0),
"at least one thread should succeed"
);
// Final state should be valid — list should work
let result = helpers::run_cmd(&env, &["list", "petstore", "--robot"]);
result.success();
// Doctor should report healthy
let result = helpers::run_cmd(&env, &["doctor", "--robot"]);
let a = result.success();
let json = helpers::parse_robot_json(&a.get_output().stdout);
// Doctor output should indicate health
assert!(
json["data"]["health"].as_str().is_some(),
"doctor should report health status"
);
}
/// Multiple threads listing the same alias concurrently should all succeed.
/// Reads are not locked (only writes acquire the lock).
#[test]
fn test_concurrent_reads_no_contention() {
let env = helpers::TestEnv::new();
helpers::fetch_fixture(&env, "petstore.json", "petstore");
let thread_count = 16;
let barrier = Arc::new(Barrier::new(thread_count));
let home_dir = env.home_dir.clone();
let handles: Vec<_> = (0..thread_count)
.map(|_| {
let barrier = Arc::clone(&barrier);
let home = home_dir.clone();
thread::spawn(move || {
barrier.wait();
let output = assert_cmd::Command::cargo_bin("swagger-cli")
.expect("binary not found")
.env("SWAGGER_CLI_HOME", &home)
.args(["list", "petstore", "--robot"])
.output()
.expect("failed to execute");
output.status.code().unwrap_or(-1)
})
})
.collect();
let exit_codes: Vec<i32> = handles.into_iter().map(|h| h.join().unwrap()).collect();
// ALL reads should succeed (no locking on reads)
for (i, code) in exit_codes.iter().enumerate() {
assert_eq!(*code, 0, "thread {i} read failed with exit code {code}");
}
}
/// Concurrent writes to DIFFERENT aliases should never interfere with each other.
#[test]
fn test_concurrent_different_aliases() {
let env = helpers::TestEnv::new();
let fixture_path = helpers::fixture_path("petstore.json");
let fixture_str = fixture_path.to_str().expect("fixture path not UTF-8");
let aliases = ["alpha", "bravo", "charlie", "delta"];
let barrier = Arc::new(Barrier::new(aliases.len()));
let home_dir = env.home_dir.clone();
let fixture = fixture_str.to_string();
let handles: Vec<_> = aliases
.iter()
.map(|alias| {
let barrier = Arc::clone(&barrier);
let home = home_dir.clone();
let fix = fixture.clone();
let a = alias.to_string();
thread::spawn(move || {
barrier.wait();
let output = assert_cmd::Command::cargo_bin("swagger-cli")
.expect("binary not found")
.env("SWAGGER_CLI_HOME", &home)
.args(["fetch", &fix, "--alias", &a])
.output()
.expect("failed to execute");
(a, output.status.code().unwrap_or(-1))
})
})
.collect();
let results: Vec<(String, i32)> = handles.into_iter().map(|h| h.join().unwrap()).collect();
// All should succeed — different aliases means different lock files
for (alias, code) in &results {
assert_eq!(*code, 0, "fetch to alias '{alias}' failed with code {code}");
}
// All aliases should be listable
for alias in &aliases {
helpers::run_cmd(&env, &["list", alias, "--robot"]).success();
}
}