tests: fast host suites

The timing rules are in two tests (upgrade_and_discovery_deadlines,
stop_deadlines). All other tests use a grace period of 0 or core.stop,
poll with deadlines instead of sleeping, and use the persistent cache in
CARGO_TARGET_TMPDIR. The 128-cycle singleton test is an ignored stress
test that nix flake check runs; the normal run has a 4-cycle sample.
Warm, the activation suite takes 12 s (was 47 s), and cargo test
--workspace takes 65 s.
This commit is contained in:
2026-10-23 08:20:49 +01:00
parent 1386acc924
commit 41818107a2
7 changed files with 134 additions and 73 deletions
+53 -26
View File
@@ -12,12 +12,10 @@ fn fixture(version: usize) -> PathBuf {
static FILES: OnceLock<Vec<PathBuf>> = OnceLock::new();
FILES.get_or_init(|| {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let target = root.join(format!(
"target/compat/activation-guests/{}",
std::process::id()
));
let target = PathBuf::from(concat!(env!("CARGO_TARGET_TMPDIR"), "/activation-guests"));
(1..=3)
.map(|version| {
let target = target.join(version.to_string());
let status = std::process::Command::new("cargo")
.env("FIXTURE_VERSION", version.to_string())
.args([
@@ -67,14 +65,11 @@ impl TestWorld {
std::fs::write(self.0.join(format!("state/{name}/1.json")), old.to_string()).unwrap();
std::fs::write(self.0.join(format!("state/{name}/2.json")), new.to_string()).unwrap();
}
/// Starts a host with an independent compile cache shared by these tests.
/// Starts a host with a persistent compile cache shared by the test suites.
async fn start(&self) -> Host {
Host::start_with_cache(
&self.0,
&Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("target/compat/runtime-cache"),
Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")),
)
.await
.unwrap()
@@ -85,9 +80,9 @@ impl Drop for TestWorld {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// Builds a method-only document with a short upgrade grace period.
/// Builds a method-only document without an upgrade delay.
fn document(name: &str) -> Value {
json!({"methods":[{"name":name,"params":[],"result":{"schema":{"type":"string"}}}],"x-sausage-grace":0.05})
json!({"methods":[{"name":name,"params":[],"result":{"schema":{"type":"string"}}}],"x-sausage-grace":0})
}
/// Adds an import of a provider's method list.
fn consumer(provider: &str, expected: Value) -> Value {
@@ -187,8 +182,7 @@ async fn review_discovery_answer_stops() {
marker(world.0.join("state/notes/discovery-ended-2"), "ended").await;
host.stop().await;
}
/// M4: a rejected candidate's discovery process never becomes a retired installed version.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
/// Checks that a rejected discovery never receives a retirement hook.
async fn review_candidate_is_not_old_version() {
use sha2::{Digest, Sha256};
let world = TestWorld::new();
@@ -246,9 +240,7 @@ async fn review_shutdown_suppresses_hooks() {
marker(world.0.join("state/notes/service"), "1").await;
activate(&host, "notes").await.unwrap();
host.stop().await;
for _ in 0..100 {
tokio::task::yield_now().await;
}
assert!(host.processes().is_empty());
assert!(!world.0.join("state/notes/retired").exists());
assert_eq!(
std::fs::read_to_string(world.0.join("state/notes/service")).unwrap(),
@@ -270,8 +262,7 @@ async fn review_shutdown_suppresses_hooks() {
1
);
}
/// L15: retirement waits for new admissions of a reverted version, not just its first snapshot.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
/// Checks retirement against live admissions after a revert.
async fn review_retirement_checks_live_version() {
let world = TestWorld::new();
let mut old = document("hold");
@@ -340,7 +331,11 @@ async fn review_watcher_is_shallow() {
let source = world.0.join("plugins/notes/source");
std::fs::create_dir_all(&source).unwrap();
std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o0)).unwrap();
let result = Host::start_with_cache(&world.0, &world.0.join("compile-cache")).await;
let result = Host::start_with_cache(
&world.0,
Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")),
)
.await;
std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o700)).unwrap();
let host = result.unwrap();
activate(&host, "notes").await.unwrap();
@@ -355,7 +350,11 @@ async fn review_watcher_failure_is_nonfatal() {
let inaccessible = world.0.join("plugins/inaccessible");
std::fs::create_dir_all(&inaccessible).unwrap();
std::fs::set_permissions(&inaccessible, std::fs::Permissions::from_mode(0o0)).unwrap();
let result = Host::start_with_cache(&world.0, &world.0.join("compile-cache")).await;
let result = Host::start_with_cache(
&world.0,
Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")),
)
.await;
std::fs::set_permissions(&inaccessible, std::fs::Permissions::from_mode(0o700)).unwrap();
let host = result.unwrap();
assert!(
@@ -368,9 +367,21 @@ async fn review_watcher_failure_is_nonfatal() {
activate(&host, "notes").await.unwrap();
host.stop().await;
}
/// L14: rapid service upgrades never overlap singleton instances.
/// PROTOCOL 8 (L14): rapid service upgrades never overlap singleton instances.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn review_singleton_restart() {
singleton_restart_cycles(4).await;
}
/// PROTOCOL 8 (L14): 128 upgrade/revert cycles stress the singleton restart race.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "stress: run by nix flake check"]
async fn singleton_restart_stress() {
singleton_restart_cycles(128).await;
}
/// Exercises service restarts without duplicating the normal and stress setup.
async fn singleton_restart_cycles(cycles: usize) {
let world = TestWorld::new();
world.plugin("notes", document("serve"), document("serve"));
std::fs::write(
@@ -379,10 +390,12 @@ async fn review_singleton_restart() {
)
.unwrap();
let host = world.start().await;
for _ in 0..128 {
for _ in 0..cycles {
activate(&host, "notes").await.unwrap();
call(&host, "core.revert", json!({"plugin":"notes"})).await;
}
activate(&host, "notes").await.unwrap();
marker(world.0.join("state/notes/service"), "2").await;
host.stop().await;
assert!(!world.0.join("state/notes/duplicate-service").exists());
}
@@ -422,14 +435,28 @@ async fn review_dropped_activation_completes() {
.unwrap();
host.stop().await;
}
/// M4: discovery times out and its process stops after either an answer or a timeout.
/// PROTOCOL 10.1: discovery fails after its fixed ten-second deadline and receives EOF.
/// PROTOCOL 3.7: old processes retain their declared grace while new calls use new code.
/// PROTOCOL 3.7: retirement waits for all live admissions, including reverted versions.
/// PROTOCOL 3.7: rejected discovery candidates never receive retirement hooks.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn upgrade_and_discovery_deadlines() {
tokio::join!(
review_discovery_deadline_and_stop(),
old_process_grace_and_new_calls(),
review_retirement_checks_live_version(),
review_candidate_is_not_old_version(),
);
}
/// Checks the fixed discovery deadline and subsequent process stop.
async fn review_discovery_deadline_and_stop() {
let world = TestWorld::new();
let mut next = document("add");
next["x-test-wait"] = json!(true);
next["x-test-no-answer"] = json!(true);
world.plugin("notes", document("add"), next);
let host = world.start().await;
let before = std::time::Instant::now();
let result = tokio::time::timeout(Duration::from_secs(15), activate(&host, "notes")).await;
assert!(
result.unwrap().unwrap_err()["message"]
@@ -437,6 +464,7 @@ async fn review_discovery_deadline_and_stop() {
.unwrap()
.contains("discovery")
);
assert!(before.elapsed() >= Duration::from_millis(9900));
assert!(
!host
.processes()
@@ -720,8 +748,7 @@ async fn graph_missing_import() {
assert!(dot.as_str().unwrap().contains("absent"));
host.stop().await;
}
/// PROTOCOL 3.7: new calls use new code while old calls retain their grace period.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
/// Checks the declared upgrade grace and dispatch to the new version.
async fn old_process_grace_and_new_calls() {
let world = TestWorld::new();
let mut old = document("add");