- 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>
171 lines
5.8 KiB
Rust
171 lines
5.8 KiB
Rust
//! 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();
|
|
}
|
|
}
|