Replace pointwise scenario testing with a reusable conformance kit in tests/common: a deterministic trace harness (input alphabet, seeded generator, naive shrinker), an invariant oracle covering twenty black-box guarantees (identity, correlation, dead-hold, attempt-fact ownership, quiescence no-op, monotonic generation, fair convergence, bounded replacement), and a fair-scheduler tail asserting eventual reconciliation. Three conformance levels run the same battery: - FakeBackend: the reference in-memory substrate (256 seeds x 2 modes) - PluginBackendAdapter over FakePlugin: seam contracts plus the battery - ProcessPlugin: real child processes, faults as real signals/errors; "no double-create" and "converged leaks nothing" verified by counting live PIDs (16 seeds) Also documents two seam findings the battery surfaced: ProvisionPlugin cannot express ambiguity (kit convention: AMBIGUOUS_FAULT_MARKER error reclassified by the adapter; definite classification leaks provider resources) and spawn_effect closures form a spawner Arc cycle that leaks backends under queue-based spawners (kit breaks it at harness drop).
1320 lines
47 KiB
Rust
1320 lines
47 KiB
Rust
//! The provisioning test kit: a reusable conformance suite for the
|
|
//! reconciler crate and any `ProvisionPlugin` implementation.
|
|
//!
|
|
//! Style: stateful property-based testing (an Erlang-QuickCheck-style
|
|
//! command-sequence test without the framework). A trace is an explicit
|
|
//! `Vec<Input>`; the harness folds it into a real `ClusterDriver` plus a
|
|
//! real `IdempotentEffectExecutor` over a backend, settling the machine
|
|
//! to quiescence after every input and checking guarantees. The oracle
|
|
//! is the invariant checker only — there is no reference model.
|
|
//! Failures panic with the seed and a shrunk minimal trace; paste that
|
|
//! trace into a plain `#[test]` to pin a regression.
|
|
//!
|
|
//! Conformance levels (one battery, three backends):
|
|
//! - `FakeBackend`: the reference in-memory substrate (fastest).
|
|
//! - `PluginBackendAdapter` over an in-memory `TestablePlugin`.
|
|
//! - `PluginBackendAdapter` over a real process-spawning plugin.
|
|
//!
|
|
//! Guarantee index (each item names its enforcement site):
|
|
//! - identity: attempts unique, ordered, never reused (oracle)
|
|
//! - correlation: pending ops belong to the current attempt (oracle)
|
|
//! - dead nodes hold nothing: Destroyed => no lease/session/pending (oracle)
|
|
//! - deleting nodes are not ready (oracle)
|
|
//! - session coherence: active session => bootstrap facts (oracle)
|
|
//! - readiness implies full facts and desired match (oracle)
|
|
//! - attempt-fact ownership: lease/session facts never leak across
|
|
//! attempts (oracle; fixture identities are attempt-encoded)
|
|
//! - resource conservation: a converged machine leaks nothing
|
|
//! (`HarnessedBackend::leaked`, checked at convergence)
|
|
//! - quiescence: a forced pass on a quiescent machine is a no-op, a
|
|
//! converged machine requeues nothing, and results are drained
|
|
//! (harness, after every settle)
|
|
//! - monotonic generation: observed_generation never regresses (harness)
|
|
//! - replay determinism: identical traces yield identical state (test)
|
|
//! - fair convergence: fault-free tails converge in bounded rounds and
|
|
//! bounded replacement attempts (harness fair tail)
|
|
//! - latest-desired-wins: a late shape change converges to it (test)
|
|
//! - run-order confluence: FIFO vs LIFO work execution converge to the
|
|
//! same state (test)
|
|
//! - deadline boundary: operations expire exactly at their deadline (test)
|
|
//! - clock extremes: saturated arithmetic never panics (test)
|
|
//! - allocator exhaustion: reported as an error, not a spin (test)
|
|
//!
|
|
//! Note on backend calls for retired attempts: an effect dispatched
|
|
//! before expiry may legitimately complete at the provider after its
|
|
//! attempt is retired (real providers have latency). The executor's
|
|
//! identity/adoption contract plus the driver's stale-result rejection
|
|
//! make that safe; what must never happen — old-attempt facts surviving
|
|
//! into a live attempt — is the attempt-fact-ownership oracle line.
|
|
|
|
// Shared across test binaries; each binary uses a different subset.
|
|
#![allow(dead_code)]
|
|
|
|
use parking_lot::Mutex;
|
|
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
|
use std::convert::Infallible;
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
|
|
use provisioning::plugin::*;
|
|
use provisioning::*;
|
|
|
|
// ── fixtures ──────────────────────────────────────────────────────────
|
|
|
|
pub fn group_with_role(id: &str, count: u32, role: &str) -> RunNodeGroupSpec {
|
|
RunNodeGroupSpec {
|
|
run_id: RunId(7),
|
|
group_id: NodeGroupId(id.to_owned()),
|
|
role: RoleId(role.to_owned()),
|
|
count,
|
|
provider: ProviderKind::new("mock"),
|
|
shape: DesiredNodeShape {
|
|
image: "node:v1".to_owned(),
|
|
disk_gb: 20,
|
|
gpu_name: None,
|
|
min_gpu_ram_mb: None,
|
|
min_down_mbps: None,
|
|
min_up_mbps: None,
|
|
min_reliability: None,
|
|
require_verified: false,
|
|
provider_labels: BTreeMap::new(),
|
|
},
|
|
boot: BootSpec {
|
|
ssh_user: "root".to_owned(),
|
|
verify_commands: vec!["true".to_owned()],
|
|
start_swactor_command: "swactor".to_owned(),
|
|
stdout_sources: Vec::new(),
|
|
stderr_sources: Vec::new(),
|
|
env: Vec::new(),
|
|
args: Vec::new(),
|
|
mounts: Vec::new(),
|
|
},
|
|
swarm_join: SwarmJoinTemplate {
|
|
orch_swactor_addr: "127.0.0.1:9000".to_owned(),
|
|
join_token_ref: "token".to_owned(),
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn group(id: &str, count: u32) -> RunNodeGroupSpec {
|
|
group_with_role(id, count, "worker")
|
|
}
|
|
|
|
pub fn shape(generation: u64, groups: Vec<RunNodeGroupSpec>) -> ClusterShape {
|
|
ClusterShape {
|
|
run_id: RunId(7),
|
|
generation,
|
|
groups,
|
|
}
|
|
}
|
|
|
|
pub fn ssh_endpoint() -> SshEndpoint {
|
|
SshEndpoint {
|
|
host: "127.0.0.1".to_owned(),
|
|
port: 22,
|
|
user: "root".to_owned(),
|
|
auth_ref: "test-key".to_owned(),
|
|
}
|
|
}
|
|
|
|
/// Lease identity encodes the owning attempt, so fact leakage across
|
|
/// attempts is detectable in pure state.
|
|
pub fn lease_result(attempt: NodeAttemptId, endpoint: bool) -> CreateLeaseResult {
|
|
let provider = ProviderKind::new("mock");
|
|
let lease_id = ProviderLeaseId(format!("lease-{}", attempt.0));
|
|
CreateLeaseResult {
|
|
lease: LeaseFacts {
|
|
provider: provider.clone(),
|
|
lease_id: lease_id.clone(),
|
|
provider_contract_id: format!("contract-{}", attempt.0),
|
|
offer_id: None,
|
|
destroy_handle: DestroyHandle {
|
|
provider,
|
|
lease_id,
|
|
provider_contract_id: format!("contract-{}", attempt.0),
|
|
},
|
|
provider_metadata: BTreeMap::new(),
|
|
},
|
|
endpoint: endpoint.then(ssh_endpoint),
|
|
}
|
|
}
|
|
|
|
/// Bootstrap session identity encodes the owning attempt (sessions are
|
|
/// `attempt * 1_000_000 + sequence`, sequences start at 1).
|
|
pub const SESSION_SEQ_SPACE: u64 = 1_000_000;
|
|
|
|
pub fn session_id_for(operation: OperationId) -> BootstrapSessionId {
|
|
assert!(
|
|
operation.sequence < SESSION_SEQ_SPACE,
|
|
"session id encoding exhausted"
|
|
);
|
|
BootstrapSessionId(operation.attempt.0 * SESSION_SEQ_SPACE + operation.sequence)
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct RecordingExecutor {
|
|
pub submitted: usize,
|
|
}
|
|
|
|
impl EffectExecutor for RecordingExecutor {
|
|
type SubmitError = Infallible;
|
|
|
|
fn submit(&mut self, _effect: &PlannedEffect) -> Result<(), Self::SubmitError> {
|
|
self.submitted += 1;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub struct NullSink;
|
|
|
|
impl PluginObservationSink for NullSink {
|
|
fn observe(&self, _observation: PluginObservation) {}
|
|
}
|
|
|
|
pub fn null_sink() -> PluginSink {
|
|
PluginSink::new(Arc::new(NullSink))
|
|
}
|
|
|
|
/// A `NodeProvisionSpec` for a concrete attempt, for direct plugin calls.
|
|
pub fn plugin_spec(attempt: u64) -> NodeProvisionSpec {
|
|
NodeProvisionSpec {
|
|
run_id: 7,
|
|
node_id: attempt,
|
|
attempt_id: attempt,
|
|
stage_index: None,
|
|
image: "kit-node".to_owned(),
|
|
env: Vec::new(),
|
|
args: Vec::new(),
|
|
mounts: Vec::new(),
|
|
}
|
|
}
|
|
|
|
// ── backend contract ──────────────────────────────────────────────────
|
|
|
|
/// Scripted answer for the next backend call. An empty script means
|
|
/// `Succeed`. `NoEndpoint` only affects lease creation (forces the
|
|
/// endpoint-probe path); every other kind treats it as `Succeed`.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub enum Reply {
|
|
Succeed,
|
|
NoEndpoint,
|
|
Definite(&'static str),
|
|
Ambiguous(&'static str),
|
|
Panic,
|
|
}
|
|
|
|
/// A backend the harness can drive: scripted faults, a healed (fair)
|
|
/// mode, and a resource-conservation probe.
|
|
pub trait HarnessedBackend: EffectBackend + Clone {
|
|
/// Script the next fault. `Reply::Succeed` means "no fault".
|
|
fn script(&mut self, reply: Reply);
|
|
/// Enter the fault-free mode (fair tail).
|
|
fn heal(&mut self);
|
|
/// Resources alive but not owned by any live lease (leaks).
|
|
fn leaked(&self) -> Vec<String>;
|
|
}
|
|
|
|
// ── reference backend: fake substrate ─────────────────────────────────
|
|
|
|
#[derive(Default)]
|
|
struct BackendShared {
|
|
scripted: Mutex<VecDeque<Reply>>,
|
|
calls: Mutex<Vec<PlannedEffect>>,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
pub struct FakeBackend {
|
|
shared: Arc<BackendShared>,
|
|
}
|
|
|
|
impl FakeBackend {
|
|
pub fn calls(&self) -> Vec<PlannedEffect> {
|
|
self.shared.calls.lock().clone()
|
|
}
|
|
}
|
|
|
|
impl HarnessedBackend for FakeBackend {
|
|
fn script(&mut self, reply: Reply) {
|
|
self.shared.scripted.lock().push_back(reply);
|
|
}
|
|
|
|
fn heal(&mut self) {
|
|
self.shared.scripted.lock().clear();
|
|
}
|
|
|
|
fn leaked(&self) -> Vec<String> {
|
|
Vec::new()
|
|
}
|
|
}
|
|
|
|
impl EffectBackend for FakeBackend {
|
|
fn execute(&self, effect: &PlannedEffect) -> Result<OperationOutcome, EffectError> {
|
|
self.shared.calls.lock().push(effect.clone());
|
|
let reply = self
|
|
.shared
|
|
.scripted
|
|
.lock()
|
|
.pop_front()
|
|
.unwrap_or(Reply::Succeed);
|
|
match reply {
|
|
Reply::Definite(reason) => return Err(EffectError::definite(reason)),
|
|
Reply::Ambiguous(reason) => return Err(EffectError::ambiguous(reason)),
|
|
Reply::Panic => panic!("scripted backend panic"),
|
|
Reply::Succeed | Reply::NoEndpoint => {}
|
|
}
|
|
let endpoint = !matches!(reply, Reply::NoEndpoint);
|
|
Ok(match &effect.command {
|
|
NodeManagerCommand::CreateLease(_) => {
|
|
OperationOutcome::LeaseCreated(lease_result(effect.operation.attempt, endpoint))
|
|
}
|
|
NodeManagerCommand::LookupEndpoint(_) => {
|
|
OperationOutcome::EndpointLookup(Some(ssh_endpoint()))
|
|
}
|
|
NodeManagerCommand::StartBootstrap(_) => OperationOutcome::BootstrapStarted {
|
|
session_id: session_id_for(effect.operation),
|
|
},
|
|
NodeManagerCommand::BootstrapConvergenceObserved { .. } => {
|
|
OperationOutcome::BootstrapConvergenceAccepted
|
|
}
|
|
NodeManagerCommand::CancelBootstrap { .. } => OperationOutcome::BootstrapCancelled,
|
|
NodeManagerCommand::DestroyLease(_) => OperationOutcome::LeaseDestroyed,
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── plugin-level kit ──────────────────────────────────────────────────
|
|
|
|
/// Fault a `TestablePlugin` can inject into its own behavior.
|
|
/// The error string a `TestablePlugin` returns for `Fault::Ambiguous`.
|
|
/// The `PluginBackendAdapter` reclassifies it as an ambiguous effect
|
|
/// error; every other plugin error is definite.
|
|
pub const AMBIGUOUS_FAULT_MARKER: &str = "kit-ambiguous: work may have happened";
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Fault {
|
|
/// Fail cleanly: the call errors and nothing is created.
|
|
Definite,
|
|
/// Fail ambiguously: the resource is created but the call errors.
|
|
/// A retry with the same attempt must adopt the resource, not
|
|
/// create a second one. The plugin reports this by returning
|
|
/// `AMBIGUOUS_FAULT_MARKER` from the call; the adapter reclassifies
|
|
/// that error as an ambiguous effect error so the driver records
|
|
/// `ambiguous_operation` and adopts on retry.
|
|
Ambiguous,
|
|
/// Panic inside the plugin call.
|
|
Panic,
|
|
/// Clear all injected faults.
|
|
Heal,
|
|
}
|
|
|
|
/// A `ProvisionPlugin` the kit can drive and probe. All probe methods
|
|
/// use interior mutability so the plugin can live behind the adapter.
|
|
pub trait TestablePlugin: ProvisionPlugin {
|
|
/// Queue the next fault (`Fault::Heal` clears all).
|
|
fn apply_fault(&self, fault: Fault);
|
|
/// Resources alive but not owned by `live_handles`.
|
|
fn leaked_resources(&self, live_handles: &[u64]) -> Vec<String>;
|
|
/// Total resources ever created (adoption must not increment this).
|
|
fn resources_created(&self) -> usize;
|
|
}
|
|
|
|
struct LiveLease {
|
|
handle: PluginNodeHandle,
|
|
endpoint: Option<SshEndpoint>,
|
|
}
|
|
|
|
struct AdapterShared<P> {
|
|
plugin: P,
|
|
live: BTreeMap<u64, LiveLease>,
|
|
withhold_endpoint: bool,
|
|
}
|
|
|
|
fn classify_plugin_error(error: String) -> EffectError {
|
|
if error == AMBIGUOUS_FAULT_MARKER {
|
|
EffectError::ambiguous(error)
|
|
} else {
|
|
EffectError::definite(error)
|
|
}
|
|
}
|
|
|
|
/// The kit's `EffectBackend` over a `ProvisionPlugin`: maps
|
|
/// `NodeManagerCommand`s to plugin calls, records live leases per
|
|
/// attempt, and synthesizes lease/session identities using the oracle's
|
|
/// attempt-encoded conventions. Create adopts: an existing live lease
|
|
/// for the same attempt is returned instead of calling the plugin
|
|
/// again (mirrors provider-side idempotency keys).
|
|
pub struct PluginBackendAdapter<P> {
|
|
shared: Arc<Mutex<AdapterShared<P>>>,
|
|
}
|
|
|
|
impl<P: TestablePlugin + 'static> PluginBackendAdapter<P> {
|
|
pub fn new(plugin: P) -> Self {
|
|
Self {
|
|
shared: Arc::new(Mutex::new(AdapterShared {
|
|
plugin,
|
|
live: BTreeMap::new(),
|
|
withhold_endpoint: false,
|
|
})),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<P: TestablePlugin + 'static> Clone for PluginBackendAdapter<P> {
|
|
fn clone(&self) -> Self {
|
|
Self { shared: Arc::clone(&self.shared) }
|
|
}
|
|
}
|
|
|
|
impl<P: TestablePlugin + 'static> HarnessedBackend for PluginBackendAdapter<P> {
|
|
fn script(&mut self, reply: Reply) {
|
|
let mut shared = self.shared.lock();
|
|
match reply {
|
|
Reply::Succeed => {}
|
|
Reply::NoEndpoint => shared.withhold_endpoint = true,
|
|
Reply::Definite(_) => shared.plugin.apply_fault(Fault::Definite),
|
|
Reply::Ambiguous(_) => shared.plugin.apply_fault(Fault::Ambiguous),
|
|
Reply::Panic => shared.plugin.apply_fault(Fault::Panic),
|
|
}
|
|
}
|
|
|
|
fn heal(&mut self) {
|
|
let mut shared = self.shared.lock();
|
|
shared.plugin.apply_fault(Fault::Heal);
|
|
shared.withhold_endpoint = false;
|
|
}
|
|
|
|
fn leaked(&self) -> Vec<String> {
|
|
let shared = self.shared.lock();
|
|
let live: Vec<u64> = shared.live.values().map(|lease| lease.handle.id).collect();
|
|
shared.plugin.leaked_resources(&live)
|
|
}
|
|
}
|
|
|
|
impl<P: TestablePlugin + 'static> EffectBackend for PluginBackendAdapter<P> {
|
|
fn execute(&self, effect: &PlannedEffect) -> Result<OperationOutcome, EffectError> {
|
|
let attempt = effect.operation.attempt.0;
|
|
let mut shared = self.shared.lock();
|
|
match &effect.command {
|
|
NodeManagerCommand::CreateLease(request) => {
|
|
if let Some(lease) = shared.live.get(&attempt) {
|
|
// Adoption: the resource for this attempt already
|
|
// exists; return it without touching the plugin.
|
|
let endpoint = lease.endpoint.clone();
|
|
return Ok(OperationOutcome::LeaseCreated(lease_result(
|
|
effect.operation.attempt,
|
|
endpoint.is_some(),
|
|
)));
|
|
}
|
|
let spec = NodeProvisionSpec {
|
|
run_id: request.spec.run_id.0,
|
|
node_id: attempt,
|
|
attempt_id: attempt,
|
|
stage_index: None,
|
|
image: request.spec.shape.image.clone(),
|
|
env: Vec::new(),
|
|
args: Vec::new(),
|
|
mounts: Vec::new(),
|
|
};
|
|
let handle = shared
|
|
.plugin
|
|
.create_node(spec, null_sink())
|
|
.map_err(classify_plugin_error)?;
|
|
let endpoint = (!shared.withhold_endpoint).then(ssh_endpoint);
|
|
shared.withhold_endpoint = false;
|
|
shared.live.insert(
|
|
attempt,
|
|
LiveLease {
|
|
handle,
|
|
endpoint: endpoint.clone(),
|
|
},
|
|
);
|
|
Ok(OperationOutcome::LeaseCreated(lease_result(
|
|
effect.operation.attempt,
|
|
endpoint.is_some(),
|
|
)))
|
|
}
|
|
NodeManagerCommand::LookupEndpoint(_) => {
|
|
// A provider resolves the node's address on each probe;
|
|
// a lease created without an endpoint gets one here
|
|
// (possibly after some None probes in a faulty world,
|
|
// but the healthy tail always resolves).
|
|
if !shared.live.contains_key(&attempt) {
|
|
return Ok(OperationOutcome::EndpointLookup(None));
|
|
}
|
|
Ok(OperationOutcome::EndpointLookup(Some(ssh_endpoint())))
|
|
}
|
|
NodeManagerCommand::StartBootstrap(_) => {
|
|
let handle = shared
|
|
.live
|
|
.get(&attempt)
|
|
.map(|lease| lease.handle.clone())
|
|
.ok_or_else(|| EffectError::definite("no live lease for bootstrap"))?;
|
|
shared
|
|
.plugin
|
|
.start_bootstrap(&handle)
|
|
.map_err(EffectError::definite)?;
|
|
Ok(OperationOutcome::BootstrapStarted {
|
|
session_id: session_id_for(effect.operation),
|
|
})
|
|
}
|
|
NodeManagerCommand::BootstrapConvergenceObserved { .. } => {
|
|
let handle = shared
|
|
.live
|
|
.get(&attempt)
|
|
.map(|lease| lease.handle.clone())
|
|
.ok_or_else(|| EffectError::definite("bootstrap lease is absent"))?;
|
|
shared
|
|
.plugin
|
|
.complete_bootstrap(&handle)
|
|
.map_err(EffectError::definite)?;
|
|
Ok(OperationOutcome::BootstrapConvergenceAccepted)
|
|
}
|
|
NodeManagerCommand::CancelBootstrap { .. } => {
|
|
if let Some(lease) = shared.live.get(&attempt) {
|
|
let handle = lease.handle.clone();
|
|
shared
|
|
.plugin
|
|
.cancel_bootstrap(&handle)
|
|
.map_err(EffectError::definite)?;
|
|
}
|
|
Ok(OperationOutcome::BootstrapCancelled)
|
|
}
|
|
NodeManagerCommand::DestroyLease(_) => {
|
|
let Some(lease) = shared.live.remove(&attempt) else {
|
|
return Ok(OperationOutcome::LeaseDestroyed);
|
|
};
|
|
shared
|
|
.plugin
|
|
.stop_node(&lease.handle)
|
|
.map_err(classify_plugin_error)?;
|
|
Ok(OperationOutcome::LeaseDestroyed)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── reference in-memory plugin ────────────────────────────────────────
|
|
|
|
#[derive(Default)]
|
|
struct FakePluginShared {
|
|
faults: VecDeque<Fault>,
|
|
/// Attempts with a live resource; the handle id is the attempt.
|
|
resources: BTreeSet<u64>,
|
|
created: usize,
|
|
}
|
|
|
|
/// The kit's reference `TestablePlugin`: resources are map entries,
|
|
/// faults are queue entries. `Fault::Ambiguous` inserts the resource
|
|
/// and fails; a retry for the same attempt adopts it.
|
|
#[derive(Clone, Default)]
|
|
pub struct FakePlugin {
|
|
shared: Arc<Mutex<FakePluginShared>>,
|
|
}
|
|
|
|
impl TestablePlugin for FakePlugin {
|
|
fn apply_fault(&self, fault: Fault) {
|
|
let mut shared = self.shared.lock();
|
|
match fault {
|
|
Fault::Heal => shared.faults.clear(),
|
|
other => shared.faults.push_back(other),
|
|
}
|
|
}
|
|
|
|
fn leaked_resources(&self, live_handles: &[u64]) -> Vec<String> {
|
|
let shared = self.shared.lock();
|
|
shared
|
|
.resources
|
|
.iter()
|
|
.filter(|attempt| !live_handles.contains(attempt))
|
|
.map(|attempt| format!("resource-{attempt}"))
|
|
.collect()
|
|
}
|
|
|
|
fn resources_created(&self) -> usize {
|
|
self.shared.lock().created
|
|
}
|
|
}
|
|
|
|
impl ProvisionPlugin for FakePlugin {
|
|
fn create_node(
|
|
&mut self,
|
|
spec: NodeProvisionSpec,
|
|
_sink: PluginSink,
|
|
) -> Result<PluginNodeHandle, String> {
|
|
let mut shared = self.shared.lock();
|
|
let attempt = spec.attempt_id;
|
|
if shared.resources.contains(&attempt) {
|
|
return Ok(PluginNodeHandle {
|
|
id: attempt,
|
|
provider_process_id: None,
|
|
});
|
|
}
|
|
let fault = shared.faults.pop_front();
|
|
if matches!(fault, Some(Fault::Panic)) {
|
|
panic!("scripted plugin panic");
|
|
}
|
|
if matches!(fault, Some(Fault::Definite)) {
|
|
return Err("scripted definite failure".to_owned());
|
|
}
|
|
shared.resources.insert(attempt);
|
|
shared.created += 1;
|
|
if matches!(fault, Some(Fault::Ambiguous)) {
|
|
return Err(AMBIGUOUS_FAULT_MARKER.to_owned());
|
|
}
|
|
Ok(PluginNodeHandle {
|
|
id: attempt,
|
|
provider_process_id: None,
|
|
})
|
|
}
|
|
|
|
fn start_bootstrap(&mut self, _handle: &PluginNodeHandle) -> Result<(), String> {
|
|
Ok(())
|
|
}
|
|
|
|
fn cancel_bootstrap(&mut self, _handle: &PluginNodeHandle) -> Result<(), String> {
|
|
Ok(())
|
|
}
|
|
|
|
fn complete_bootstrap(&mut self, _handle: &PluginNodeHandle) -> Result<(), String> {
|
|
Ok(())
|
|
}
|
|
|
|
fn stop_node(&mut self, handle: &PluginNodeHandle) -> Result<(), String> {
|
|
self.shared.lock().resources.remove(&handle.id);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ── input alphabet ────────────────────────────────────────────────────
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub enum BootEvent {
|
|
Joined,
|
|
Closed,
|
|
Failed,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub enum Input {
|
|
/// Advance the clock. The only source of time.
|
|
Tick(Duration),
|
|
/// Update the desired shape. The generator guarantees legal
|
|
/// generations (strictly increasing), so this never errors.
|
|
Shape(ClusterShape),
|
|
/// Script the backend's next reply.
|
|
Reply(Reply),
|
|
/// Execute all dispatched-but-unexecuted backend work.
|
|
Run,
|
|
/// Deliver an external bootstrap observation to every node with an
|
|
/// active bootstrap session, in sorted node order.
|
|
Boot(BootEvent),
|
|
}
|
|
|
|
impl Input {
|
|
pub fn describe(&self) -> String {
|
|
match self {
|
|
Input::Tick(duration) => format!("tick({duration:?})"),
|
|
Input::Shape(shape) => {
|
|
let first = shape.groups.first();
|
|
format!(
|
|
"shape(gen={}, count={}, role={})",
|
|
shape.generation,
|
|
first.map_or(0, |g| g.count),
|
|
first.map_or("-", |g| g.role.0.as_str()),
|
|
)
|
|
}
|
|
Input::Reply(reply) => format!("reply({reply:?})"),
|
|
Input::Run => "run".to_owned(),
|
|
Input::Boot(event) => format!("boot({event:?})"),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn describe_trace(trace: &[Input]) -> String {
|
|
trace
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, input)| format!(" [{index}] {}\n", input.describe()))
|
|
.collect()
|
|
}
|
|
|
|
// ── oracle ────────────────────────────────────────────────────────────
|
|
|
|
/// Guarantees that must hold in every reachable state, checked after
|
|
/// every settle. `desired` is the expanded desired shape the driver is
|
|
/// converging toward.
|
|
pub fn check_invariants(
|
|
state: &ClusterState,
|
|
desired: &BTreeMap<LogicalNodeId, LogicalNodeSpec>,
|
|
) -> Result<(), String> {
|
|
let mut attempts = BTreeSet::new();
|
|
for (id, node) in &state.nodes {
|
|
if !attempts.insert(node.attempt) {
|
|
return Err(format!("attempt {} reused across nodes", node.attempt.0));
|
|
}
|
|
if node.attempt.0 >= state.next_attempt_id {
|
|
return Err(format!(
|
|
"node {id:?}: attempt {} not below allocator {}",
|
|
node.attempt.0, state.next_attempt_id
|
|
));
|
|
}
|
|
if let Some(pending) = &node.pending {
|
|
if pending.id.attempt != node.attempt {
|
|
return Err(format!(
|
|
"node {id:?}: pending operation {}:{} does not belong to attempt {}",
|
|
pending.id.attempt.0, pending.id.sequence, node.attempt.0
|
|
));
|
|
}
|
|
if pending.id.sequence >= node.next_operation_sequence {
|
|
return Err(format!(
|
|
"node {id:?}: pending sequence {} not below next {}",
|
|
pending.id.sequence, node.next_operation_sequence
|
|
));
|
|
}
|
|
}
|
|
if node.intent == NodeIntent::Deleting && node.record.ready {
|
|
return Err(format!("node {id:?}: Deleting but ready"));
|
|
}
|
|
if node.record.stage == NodeStage::Destroyed {
|
|
if node.record.lease.is_some() {
|
|
return Err(format!("node {id:?}: Destroyed with live lease"));
|
|
}
|
|
if node.active_bootstrap.is_some() {
|
|
return Err(format!("node {id:?}: Destroyed with active bootstrap"));
|
|
}
|
|
if node.pending.is_some() {
|
|
return Err(format!("node {id:?}: Destroyed with pending operation"));
|
|
}
|
|
}
|
|
if node.active_bootstrap.is_some() && node.record.bootstrap.is_none() {
|
|
return Err(format!("node {id:?}: active session without bootstrap facts"));
|
|
}
|
|
// Attempt-fact ownership: fixture identities are attempt-encoded,
|
|
// so a fact from another attempt is detectable here.
|
|
if let Some(lease) = &node.record.lease
|
|
&& lease.lease_id.0 != format!("lease-{}", node.attempt.0) {
|
|
return Err(format!(
|
|
"node {id:?}: lease {} leaked from another attempt",
|
|
lease.lease_id.0
|
|
));
|
|
}
|
|
for session in [node.active_bootstrap, node.record.bootstrap.as_ref().map(|f| f.session_id)]
|
|
.into_iter()
|
|
.flatten()
|
|
{
|
|
let owning_attempt = session.0 / SESSION_SEQ_SPACE;
|
|
let sequence = session.0 % SESSION_SEQ_SPACE;
|
|
if owning_attempt != node.attempt.0 || sequence == 0 {
|
|
return Err(format!(
|
|
"node {id:?}: bootstrap session {} leaked from another attempt",
|
|
session.0
|
|
));
|
|
}
|
|
}
|
|
// Readiness implies full facts and agreement with desired.
|
|
if node.record.ready {
|
|
if node.intent != NodeIntent::Active {
|
|
return Err(format!("node {id:?}: ready but intent {:?}", node.intent));
|
|
}
|
|
if node.record.lease.is_none() || node.record.connection.is_none() {
|
|
return Err(format!("node {id:?}: ready without live lease/connection"));
|
|
}
|
|
if node
|
|
.record
|
|
.swactor
|
|
.as_ref().is_none_or(|swactor| swactor.handed_off_at.is_none())
|
|
{
|
|
return Err(format!("node {id:?}: ready without completed swactor handoff"));
|
|
}
|
|
match desired.get(id) {
|
|
Some(spec) if node.record.desired == *spec => {}
|
|
_ => {
|
|
return Err(format!(
|
|
"node {id:?}: ready but does not match the desired shape"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ── spawner ───────────────────────────────────────────────────────────
|
|
|
|
/// Order in which a deferred batch of blocking work is executed; used by
|
|
/// the confluence guarantee (permuting independent deliveries within a
|
|
/// round must not change the converged state).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
|
pub enum RunOrder {
|
|
#[default]
|
|
Fifo,
|
|
Lifo,
|
|
}
|
|
|
|
/// Defers all blocking work: dispatched effects queue up and execute only
|
|
/// when the harness runs them (`Input::Run`). This makes in-flight effects
|
|
/// that outlive deadlines reachable.
|
|
#[derive(Clone, Default)]
|
|
pub struct DeferredSpawner {
|
|
work: Arc<Mutex<Vec<BlockingEffectWork>>>,
|
|
order: RunOrder,
|
|
}
|
|
|
|
impl DeferredSpawner {
|
|
pub fn run_all(&self) {
|
|
loop {
|
|
let mut work = std::mem::take(&mut *self.work.lock());
|
|
if work.is_empty() {
|
|
return;
|
|
}
|
|
if self.order == RunOrder::Lifo {
|
|
work.reverse();
|
|
}
|
|
for operation in work {
|
|
operation();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl DeferredSpawner {
|
|
/// Drops all queued-but-never-run work. `spawn_effect` closures
|
|
/// capture the spawner (to promote queued adoption work), so a
|
|
/// queue-based spawner holds a closure→spawner→queue Arc cycle;
|
|
/// clearing the queue breaks it and releases the backend.
|
|
pub fn clear(&self) {
|
|
self.work.lock().clear();
|
|
}
|
|
}
|
|
|
|
impl BlockingEffectSpawner for DeferredSpawner {
|
|
type SpawnError = Infallible;
|
|
|
|
fn spawn_blocking(&self, work: BlockingEffectWork) -> Result<(), Self::SpawnError> {
|
|
self.work.lock().push(work);
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// ── harness ───────────────────────────────────────────────────────────
|
|
|
|
pub const SETTLE_LIMIT: usize = 1_000;
|
|
pub const FAIR_ROUNDS: usize = 64;
|
|
pub const FAIR_TICK: Duration = Duration::from_secs(3_600);
|
|
|
|
pub struct Harness<B: HarnessedBackend> {
|
|
pub driver: ClusterDriver,
|
|
pub executor: IdempotentEffectExecutor<B, DeferredSpawner>,
|
|
pub backend: B,
|
|
spawner: DeferredSpawner,
|
|
now: SystemTime,
|
|
seed: u64,
|
|
trace: Vec<Input>,
|
|
desired_expanded: BTreeMap<LogicalNodeId, LogicalNodeSpec>,
|
|
last_observed_generation: Option<u64>,
|
|
}
|
|
|
|
impl<B: HarnessedBackend> Harness<B> {
|
|
pub fn new_with_backend(seed: u64, initial: ClusterShape, backend: B) -> Self {
|
|
Self::new_ordered(seed, initial, backend, RunOrder::Fifo)
|
|
}
|
|
|
|
pub fn new_ordered(seed: u64, initial: ClusterShape, backend: B, order: RunOrder) -> Self {
|
|
let desired_expanded = initial.expand().expect("initial shape expands");
|
|
let spawner = DeferredSpawner {
|
|
work: Arc::new(Mutex::new(Vec::new())),
|
|
order,
|
|
};
|
|
Self {
|
|
driver: ClusterDriver::new(initial, RetryPolicy::default()).unwrap(),
|
|
executor: IdempotentEffectExecutor::new(backend.clone(), spawner.clone()),
|
|
backend,
|
|
spawner,
|
|
now: UNIX_EPOCH,
|
|
seed,
|
|
trace: Vec::new(),
|
|
desired_expanded,
|
|
last_observed_generation: None,
|
|
}
|
|
}
|
|
|
|
pub fn step(&mut self, input: Input) {
|
|
self.trace.push(input.clone());
|
|
match input {
|
|
Input::Tick(duration) => {
|
|
self.now = self
|
|
.now
|
|
.checked_add(duration)
|
|
.expect("harness clock overflow");
|
|
}
|
|
Input::Shape(shape) => {
|
|
let expanded = shape.expand().expect("generator produced an invalid shape");
|
|
self.driver
|
|
.update_desired(shape)
|
|
.expect("generator produced an illegal shape");
|
|
self.desired_expanded = expanded;
|
|
}
|
|
Input::Reply(reply) => self.backend.script(reply),
|
|
Input::Run => self.spawner.run_all(),
|
|
Input::Boot(event) => {
|
|
self.deliver_boot(event);
|
|
}
|
|
}
|
|
self.settle();
|
|
self.check();
|
|
}
|
|
|
|
pub fn settle(&mut self) {
|
|
for iteration in 0..SETTLE_LIMIT {
|
|
let mut progress = false;
|
|
let results = self.executor.drain_results();
|
|
for result in results {
|
|
self.driver.apply_executor_result(result, self.now);
|
|
progress = true;
|
|
}
|
|
for operation in self.driver.pending_operations_due(self.now) {
|
|
if self
|
|
.executor
|
|
.expire(operation.operation, "deadline elapsed in harness")
|
|
{
|
|
progress = true;
|
|
}
|
|
}
|
|
if self.driver.trigger_if_due(self.now) {
|
|
progress = true;
|
|
}
|
|
let submitted = self
|
|
.driver
|
|
.drive_until_blocked(self.now, &mut self.executor)
|
|
.expect("drive failed in harness");
|
|
progress |= submitted > 0;
|
|
if !progress {
|
|
break;
|
|
}
|
|
if iteration + 1 == SETTLE_LIMIT {
|
|
self.fail(&format!(
|
|
"machine did not reach quiescence within {SETTLE_LIMIT} settle iterations (livelock)"
|
|
));
|
|
}
|
|
}
|
|
self.after_settle();
|
|
}
|
|
|
|
/// Machine-level guarantees, checked at every quiescent point.
|
|
fn after_settle(&mut self) {
|
|
// No orphaned external work: results are drained at quiescence.
|
|
if !self.executor.drain_results().is_empty() {
|
|
self.fail("quiescent machine left executor results undrained");
|
|
}
|
|
// Monotonic generation.
|
|
let generation = self.driver.state().observed_generation;
|
|
if let Some(previous) = self.last_observed_generation
|
|
&& generation < previous
|
|
{
|
|
self.fail("observed_generation regressed");
|
|
}
|
|
self.last_observed_generation = Some(generation);
|
|
// Pass idempotency and quiescence: a forced pass over a
|
|
// quiescent machine dispatches nothing, mutates nothing, and
|
|
// keeps the requeue deadline; a converged machine has no
|
|
// requeue deadline at all.
|
|
let before = self.driver.state().clone();
|
|
let requeue_before = self.driver.requeue_at();
|
|
self.driver.trigger();
|
|
let submitted = self
|
|
.driver
|
|
.drive_next(self.now, &mut self.executor)
|
|
.expect("forced pass failed");
|
|
if submitted != 0 {
|
|
self.fail("quiescent machine dispatched effects on a forced pass");
|
|
}
|
|
if *self.driver.state() != before {
|
|
self.fail("forced pass mutated quiescent state");
|
|
}
|
|
if self.driver.requeue_at() != requeue_before {
|
|
self.fail("forced pass changed the requeue deadline");
|
|
}
|
|
if self.driver.is_converged() {
|
|
if self.driver.requeue_at().is_some() {
|
|
self.fail("converged machine scheduled a requeue");
|
|
}
|
|
// Resource conservation: convergence owns every live resource.
|
|
let leaked = self.backend.leaked();
|
|
if !leaked.is_empty() {
|
|
self.fail(&format!(
|
|
"converged machine leaked resources: {leaked:?}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn deliver_boot(&mut self, event: BootEvent) {
|
|
let targets: Vec<(LogicalNodeId, NodeAttemptId, BootstrapSessionId)> = self
|
|
.driver
|
|
.state()
|
|
.nodes
|
|
.iter()
|
|
.filter_map(|(id, node)| {
|
|
node.active_bootstrap
|
|
.map(|session| (id.clone(), node.attempt, session))
|
|
})
|
|
.collect();
|
|
for (id, attempt, session) in targets {
|
|
let observation = match event {
|
|
BootEvent::Joined => NodeObservation::SwactorJoined {
|
|
session_id: session,
|
|
swactor_id: SwactorId(format!("sw-{}-{}", id.0, attempt.0)),
|
|
},
|
|
BootEvent::Closed => NodeObservation::BootstrapClosed { session_id: session },
|
|
BootEvent::Failed => NodeObservation::BootstrapFailed {
|
|
session_id: session,
|
|
reason: "scripted bootstrap failure".to_owned(),
|
|
},
|
|
};
|
|
self.driver.apply_observation(&id, attempt, observation, self.now);
|
|
}
|
|
}
|
|
|
|
/// Fair scheduler: stop injecting faults, deliver bootstrap
|
|
/// completion, run dispatched work, advance past every deadline.
|
|
/// A healthy machine must converge within `FAIR_ROUNDS` rounds and
|
|
/// must not exceed a bounded replacement-attempt budget (unbounded
|
|
/// replacement under a fault-free tail is an infinite retry loop).
|
|
pub fn fair_tail(&mut self) {
|
|
self.backend.heal();
|
|
let attempts_before = self.driver.state().next_attempt_id;
|
|
let budget = (self.driver.state().nodes.len() as u64).saturating_mul(2) + 2;
|
|
for round in 0..FAIR_ROUNDS {
|
|
self.deliver_boot(BootEvent::Joined);
|
|
self.settle();
|
|
self.deliver_boot(BootEvent::Closed);
|
|
self.settle();
|
|
self.spawner.run_all();
|
|
self.now = self
|
|
.now
|
|
.checked_add(FAIR_TICK)
|
|
.expect("harness clock overflow");
|
|
self.settle();
|
|
self.check();
|
|
if self.driver.is_converged() {
|
|
return;
|
|
}
|
|
let allocated = self.driver.state().next_attempt_id - attempts_before;
|
|
if allocated > budget {
|
|
self.fail(&format!(
|
|
"fair tail allocated {allocated} replacement attempts (budget {budget}): unbounded retry"
|
|
));
|
|
}
|
|
if round + 1 == FAIR_ROUNDS {
|
|
self.fail(&format!(
|
|
"fair trace did not converge within {FAIR_ROUNDS} rounds"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn check(&self) {
|
|
if let Err(violation) = check_invariants(self.driver.state(), &self.desired_expanded) {
|
|
self.fail(&format!("invariant violated: {violation}"));
|
|
}
|
|
}
|
|
|
|
fn fail(&self, message: &str) -> ! {
|
|
panic!(
|
|
"\n[{message}]\nseed: {}\ntrace ({} inputs):\n{}",
|
|
self.seed,
|
|
self.trace.len(),
|
|
describe_trace(&self.trace),
|
|
);
|
|
}
|
|
|
|
pub fn state(&self) -> &ClusterState {
|
|
self.driver.state()
|
|
}
|
|
|
|
|
|
}
|
|
|
|
impl<B: HarnessedBackend> Drop for Harness<B> {
|
|
fn drop(&mut self) {
|
|
// Break the spawner's closure cycle for never-run work so the
|
|
// backend (and anything it owns, e.g. child processes) releases.
|
|
self.spawner.clear();
|
|
}
|
|
}
|
|
|
|
// ── deterministic generator ───────────────────────────────────────────
|
|
|
|
pub struct Rng(u64);
|
|
|
|
impl Rng {
|
|
pub fn next(&mut self) -> u64 {
|
|
self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
|
|
let mut z = self.0;
|
|
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
|
|
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
|
|
z ^ (z >> 31)
|
|
}
|
|
}
|
|
|
|
pub struct GenCtx {
|
|
generation: u64,
|
|
role_alt: bool,
|
|
}
|
|
|
|
fn gen_input(ctx: &mut GenCtx, rng: &mut Rng) -> Input {
|
|
match rng.next() % 100 {
|
|
0..=39 => Input::Tick(Duration::from_secs(
|
|
[1, 10, 61, 130, 3_600][(rng.next() % 5) as usize],
|
|
)),
|
|
40..=61 => Input::Run,
|
|
62..=79 => {
|
|
let reply = match rng.next() % 100 {
|
|
0..=59 => Reply::Succeed,
|
|
60..=74 => Reply::NoEndpoint,
|
|
75..=84 => Reply::Definite("scripted definite failure"),
|
|
85..=94 => Reply::Ambiguous("scripted ambiguous failure"),
|
|
_ => Reply::Panic,
|
|
};
|
|
Input::Reply(reply)
|
|
}
|
|
80..=86 => Input::Boot(match rng.next() % 3 {
|
|
0 => BootEvent::Joined,
|
|
1 => BootEvent::Closed,
|
|
_ => BootEvent::Failed,
|
|
}),
|
|
_ => {
|
|
ctx.generation += 1;
|
|
ctx.role_alt ^= rng.next().is_multiple_of(2);
|
|
let count = (rng.next() % 4) as u32;
|
|
let role = if ctx.role_alt { "worker-alt" } else { "worker" };
|
|
Input::Shape(shape(
|
|
ctx.generation,
|
|
vec![group_with_role("g0", count, role)],
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn gen_trace(seed: u64, len: usize) -> Vec<Input> {
|
|
let mut rng = Rng(seed);
|
|
let mut ctx = GenCtx {
|
|
generation: 1,
|
|
role_alt: false,
|
|
};
|
|
(0..len).map(|_| gen_input(&mut ctx, &mut rng)).collect()
|
|
}
|
|
|
|
/// Replaces scripted faults with `Succeed`, for metamorphic comparisons
|
|
/// where outcome assignment must not depend on execution order.
|
|
pub fn sanitized(trace: &[Input]) -> Vec<Input> {
|
|
trace
|
|
.iter()
|
|
.map(|input| match input {
|
|
Input::Reply(_) => Input::Reply(Reply::Succeed),
|
|
other => other.clone(),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
// ── execution and shrinking ───────────────────────────────────────────
|
|
|
|
pub fn run_trace_with<B: HarnessedBackend>(
|
|
make: impl Fn() -> B,
|
|
seed: u64,
|
|
trace: &[Input],
|
|
fair: bool,
|
|
order: RunOrder,
|
|
) -> Harness<B> {
|
|
let mut harness = Harness::new_ordered(
|
|
seed,
|
|
shape(1, vec![group("g0", 1)]),
|
|
make(),
|
|
order,
|
|
);
|
|
for input in trace {
|
|
harness.step(input.clone());
|
|
}
|
|
if fair {
|
|
harness.fair_tail();
|
|
if !harness.driver.is_converged() {
|
|
harness.fail("fair trace did not converge");
|
|
}
|
|
}
|
|
harness
|
|
}
|
|
|
|
pub fn run_trace(
|
|
seed: u64,
|
|
trace: &[Input],
|
|
fair: bool,
|
|
order: RunOrder,
|
|
) -> Harness<FakeBackend> {
|
|
run_trace_with(FakeBackend::default, seed, trace, fair, order)
|
|
}
|
|
|
|
fn still_fails<B: HarnessedBackend>(
|
|
make: impl Fn() -> B + Clone,
|
|
seed: u64,
|
|
trace: &[Input],
|
|
fair: bool,
|
|
) -> bool {
|
|
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
run_trace_with(make.clone(), seed, trace, fair, RunOrder::Fifo)
|
|
}))
|
|
.is_err()
|
|
}
|
|
|
|
/// Naive shrinker: halve suffixes, then drop single inputs until a
|
|
/// fixpoint. Re-runs with the same seed; legality of remaining shapes is
|
|
/// preserved because generations are strictly increasing as data.
|
|
fn shrink<B: HarnessedBackend>(
|
|
make: impl Fn() -> B + Clone,
|
|
seed: u64,
|
|
trace: &[Input],
|
|
fair: bool,
|
|
) -> Vec<Input> {
|
|
let mut current = trace.to_vec();
|
|
loop {
|
|
let mut reduced = false;
|
|
while current.len() > 1 {
|
|
let half = current[..current.len() / 2].to_vec();
|
|
if still_fails(make.clone(), seed, &half, fair) {
|
|
current = half;
|
|
reduced = true;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
for index in 0..current.len() {
|
|
let mut candidate = current.clone();
|
|
candidate.remove(index);
|
|
if still_fails(make.clone(), seed, &candidate, fair) {
|
|
current = candidate;
|
|
reduced = true;
|
|
break;
|
|
}
|
|
}
|
|
if !reduced {
|
|
return current;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Runs a trace, shrinking and reporting on failure. Used by the seeded
|
|
/// loops; the default panic hook is silenced while shrinking.
|
|
pub fn assert_trace<B: HarnessedBackend>(
|
|
make: impl Fn() -> B + Clone,
|
|
seed: u64,
|
|
trace: &[Input],
|
|
fair: bool,
|
|
) {
|
|
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
run_trace_with(make.clone(), seed, trace, fair, RunOrder::Fifo)
|
|
}));
|
|
if outcome.is_ok() {
|
|
return;
|
|
}
|
|
let previous_hook = std::panic::take_hook();
|
|
std::panic::set_hook(Box::new(|_| {}));
|
|
let minimal = shrink(make, seed, trace, fair);
|
|
std::panic::set_hook(previous_hook);
|
|
panic!(
|
|
"\nseed: {seed}\nminimal failing trace ({} inputs):\n{}",
|
|
minimal.len(),
|
|
describe_trace(&minimal),
|
|
);
|
|
}
|
|
|
|
/// The full battery: adversarial invariants plus fair convergence, over
|
|
/// a fresh backend per run.
|
|
pub fn run_trace_battery<B: HarnessedBackend>(make: impl Fn() -> B + Clone, seeds: u64, len: usize) {
|
|
for seed in 0..seeds {
|
|
let trace = gen_trace(seed, len);
|
|
assert_trace(make.clone(), seed, &trace, false);
|
|
assert_trace(make.clone(), seed, &trace, true);
|
|
}
|
|
}
|
|
|
|
// ── plugin contracts ──────────────────────────────────────────────────
|
|
|
|
/// Seam-level contracts every `TestablePlugin` must satisfy, checked by
|
|
/// direct plugin calls (no driver involved):
|
|
/// - create is idempotent per attempt (retry adopts, never re-creates),
|
|
/// - a definite failure creates nothing,
|
|
/// - an ambiguous failure creates the resource; retry adopts it,
|
|
/// - stop releases the resource (nothing leaks),
|
|
/// - bootstrap lifecycle calls succeed on a live handle.
|
|
pub fn assert_plugin_contracts<P: TestablePlugin>(plugin: &mut P) {
|
|
let sink = null_sink();
|
|
let before = plugin.resources_created();
|
|
|
|
// Create twice for the same attempt: one resource, same handle.
|
|
let first = plugin
|
|
.create_node(plugin_spec(11), sink.clone())
|
|
.expect("create succeeds");
|
|
let second = plugin
|
|
.create_node(plugin_spec(11), sink.clone())
|
|
.expect("create adopts");
|
|
assert_eq!(first.id, second.id, "retry must adopt, not re-create");
|
|
assert_eq!(
|
|
plugin.resources_created(),
|
|
before + 1,
|
|
"adoption must not create a second resource"
|
|
);
|
|
|
|
// Definite failure creates nothing.
|
|
plugin.apply_fault(Fault::Definite);
|
|
let failed = plugin.create_node(plugin_spec(12), sink.clone());
|
|
assert!(failed.is_err(), "definite fault must fail");
|
|
assert_eq!(
|
|
plugin.resources_created(),
|
|
before + 1,
|
|
"definite failure must not create a resource"
|
|
);
|
|
plugin.apply_fault(Fault::Heal);
|
|
|
|
// Ambiguous failure creates the resource; retry adopts it.
|
|
plugin.apply_fault(Fault::Ambiguous);
|
|
let ambiguous = plugin.create_node(plugin_spec(13), sink.clone());
|
|
assert!(ambiguous.is_err(), "ambiguous fault must fail");
|
|
assert_eq!(
|
|
plugin.resources_created(),
|
|
before + 2,
|
|
"ambiguous failure must have created the resource"
|
|
);
|
|
plugin.apply_fault(Fault::Heal);
|
|
let adopted = plugin
|
|
.create_node(plugin_spec(13), sink.clone())
|
|
.expect("retry after ambiguity must adopt");
|
|
assert_eq!(
|
|
plugin.resources_created(),
|
|
before + 2,
|
|
"adoption must not create a second resource"
|
|
);
|
|
assert!(
|
|
plugin
|
|
.leaked_resources(&[first.id, adopted.id])
|
|
.is_empty(),
|
|
"owned resources are not leaks"
|
|
);
|
|
|
|
// Bootstrap lifecycle on a live handle.
|
|
plugin
|
|
.start_bootstrap(&first)
|
|
.expect("start_bootstrap succeeds");
|
|
plugin
|
|
.complete_bootstrap(&first)
|
|
.expect("complete_bootstrap succeeds");
|
|
plugin
|
|
.cancel_bootstrap(&first)
|
|
.expect("cancel_bootstrap succeeds");
|
|
|
|
// Stop releases everything.
|
|
plugin.stop_node(&first).expect("stop succeeds");
|
|
plugin.stop_node(&adopted).expect("stop succeeds");
|
|
assert!(
|
|
plugin.leaked_resources(&[]).is_empty(),
|
|
"stopped resources must be released"
|
|
);
|
|
}
|