use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use arcbox_vm_driver::testkit::FakeDriver;
use arcbox_vm_driver::{BootSpec, ConsoleSpec, IsolationSpec, VmDriver, VmId, VmSpec};
use async_trait::async_trait;
use tokio::sync::{broadcast, mpsc, oneshot, watch};
use crate::agent::{GuestAgent, GuestAgentFactory};
use crate::config::RuntimeConfig;
use crate::error::{ComputerError, Result};
use crate::lifecycle::actor::{
Command, ComputerActor, ComputerSeed, ComputerSnapshot, Deadlines, Seeded, WorkloadOutcome,
};
use crate::lifecycle::effect::ReleaseScope;
use crate::lifecycle::event::{PauseReason, Provision, RestoreOrigin};
use crate::lifecycle::runtime::ComputerRuntime;
use crate::lifecycle::tasks::{CaptureSpec, ComputerTasks, Drain, TaskFailure, TaskResult};
use crate::sandbox::reconcile::{ComputerStateRecord, write_state_record};
use crate::sandbox::record::{ComputerProvisionOutcome, ComputerRecordStore};
use crate::sandbox::workload::WorkloadClaim;
use crate::sandbox::{
CheckpointInfo, ComputerEvent, ComputerSpec, ComputerState, IdleAction, LifecycleUpdate,
};
use crate::testkit::agent::FakeAgentFactory;
#[derive(Clone, Copy)]
enum Boot {
HandsOffThenHangs,
HandsOffAfter(Duration),
HandsOffAndCompletes,
HandsOffThenPanics,
DropsHandoffThenCleansUp,
Completes,
}
struct Script {
boot: Boot,
agent: Arc<dyn GuestAgent>,
calls: Mutex<Vec<&'static str>>,
release_fails: AtomicBool,
restore_takes: Mutex<Option<Duration>>,
restore_finished: AtomicBool,
restore_fails: AtomicBool,
checkpoint_takes: Mutex<Option<Duration>>,
release_takes: Mutex<Option<Duration>>,
gate_takes: Mutex<Option<Duration>>,
cleanup_finished: AtomicBool,
detach_fails: AtomicBool,
stop_takes: Mutex<Option<Duration>>,
detach_takes: Mutex<Option<Duration>>,
}
impl Script {
fn new(boot: Boot, agent: Arc<dyn GuestAgent>) -> Arc<Self> {
Arc::new(Self {
boot,
agent,
calls: Mutex::new(Vec::new()),
release_fails: AtomicBool::new(false),
restore_takes: Mutex::new(None),
restore_finished: AtomicBool::new(false),
restore_fails: AtomicBool::new(false),
checkpoint_takes: Mutex::new(None),
release_takes: Mutex::new(None),
gate_takes: Mutex::new(None),
cleanup_finished: AtomicBool::new(false),
detach_fails: AtomicBool::new(false),
stop_takes: Mutex::new(None),
detach_takes: Mutex::new(None),
})
}
fn record(&self, call: &'static str) {
self.calls.lock().unwrap().push(call);
}
fn calls(&self) -> Vec<&'static str> {
self.calls.lock().unwrap().clone()
}
}
#[async_trait]
impl ComputerTasks for Script {
async fn boot(
&self,
_warm: bool,
handed_off: oneshot::Sender<()>,
) -> TaskResult<Arc<dyn GuestAgent>> {
self.record("boot");
match self.boot {
Boot::HandsOffThenHangs => {
let _ = handed_off.send(());
std::future::pending::<()>().await;
unreachable!("a hanging boot is only ever aborted")
}
Boot::HandsOffAfter(delay) => {
tokio::time::sleep(delay).await;
let _ = handed_off.send(());
std::future::pending::<()>().await;
unreachable!("a hanging boot is only ever aborted")
}
Boot::DropsHandoffThenCleansUp => {
drop(handed_off);
tokio::time::sleep(Duration::from_secs(2)).await;
self.cleanup_finished.store(true, Ordering::SeqCst);
Err(TaskFailure::recoverable(ComputerError::Process(
"the vmm would not spawn".into(),
)))
}
Boot::HandsOffThenPanics => {
let _ = handed_off.send(());
panic!("the boot task fell over")
}
Boot::HandsOffAndCompletes | Boot::Completes => {
let _ = handed_off.send(());
Ok(Arc::clone(&self.agent))
}
}
}
async fn gate(&self) -> TaskResult {
self.record("gate");
let takes = *self.gate_takes.lock().unwrap();
if let Some(takes) = takes {
tokio::time::sleep(takes).await;
}
Ok(())
}
async fn restore(
&self,
_origin: RestoreOrigin,
) -> TaskResult<(Arc<dyn GuestAgent>, ComputerProvisionOutcome)> {
self.record("restore");
let takes = *self.restore_takes.lock().unwrap();
if let Some(takes) = takes {
tokio::time::sleep(takes).await;
}
self.restore_finished.store(true, Ordering::SeqCst);
if self.restore_fails.load(Ordering::SeqCst) {
return Err(TaskFailure::recoverable(ComputerError::Process(
"the checkpoint would not load".into(),
)));
}
Ok((Arc::clone(&self.agent), ComputerProvisionOutcome::default()))
}
async fn checkpoint(
&self,
_hold: bool,
_spec: Option<CaptureSpec>,
) -> TaskResult<CheckpointInfo> {
self.record("checkpoint");
let takes = *self.checkpoint_takes.lock().unwrap();
if let Some(takes) = takes {
tokio::time::sleep(takes).await;
}
Ok(CheckpointInfo {
snapshot_id: "snap".to_owned(),
snapshot_dir: String::new(),
created_at: String::new(),
})
}
async fn resume(&self) -> TaskResult<Arc<dyn GuestAgent>> {
self.record("resume");
Ok(Arc::clone(&self.agent))
}
async fn stop(&self, _budget: Duration, _drain: Drain) -> TaskResult {
self.record("stop");
let takes = *self.stop_takes.lock().unwrap();
if let Some(takes) = takes {
tokio::time::sleep(takes).await;
}
Ok(())
}
async fn detach(&self) -> TaskResult {
self.record("detach");
let takes = *self.detach_takes.lock().unwrap();
if let Some(takes) = takes {
tokio::time::sleep(takes).await;
}
if self.detach_fails.load(Ordering::SeqCst) {
return Err(TaskFailure::recoverable(ComputerError::Process(
"the vmm would not be handed over".into(),
)));
}
Ok(())
}
fn adopted_agent(&self) -> Option<Arc<dyn GuestAgent>> {
Some(Arc::clone(&self.agent))
}
async fn release(&self, _scope: ReleaseScope) -> TaskResult {
self.record("release");
let takes = *self.release_takes.lock().unwrap();
if let Some(takes) = takes {
tokio::time::sleep(takes).await;
}
if self.release_fails.load(Ordering::SeqCst) {
return Err(TaskFailure::recoverable(ComputerError::Process(
"the vmm would not die".into(),
)));
}
Ok(())
}
}
struct Harness {
commands: mpsc::UnboundedSender<Command>,
registered: Arc<AtomicBool>,
runtime: crate::lifecycle::runtime::Runtime,
snapshot: watch::Receiver<ComputerSnapshot>,
events: broadcast::Receiver<ComputerEvent>,
script: Arc<Script>,
actor: tokio::task::JoinHandle<()>,
_timers: watch::Sender<bool>,
dir: tempfile::TempDir,
}
impl Harness {
async fn start(boot: Boot, deadlines: Deadlines) -> Self {
Self::started(boot, deadlines, false, false).await
}
async fn recorded(boot: Boot, deadlines: Deadlines) -> Self {
Self::started(boot, deadlines, false, true).await
}
async fn journalled(boot: Boot, deadlines: Deadlines) -> Self {
Self::started(boot, deadlines, true, false).await
}
async fn started(boot: Boot, deadlines: Deadlines, journal: bool, record: bool) -> Self {
let dir = tempfile::tempdir().unwrap();
let script = Script::new(boot, agent().await);
let events_tx = Arc::new(crate::sandbox::events::EventBus::new(64));
let events = events_tx.subscribe();
let (commands, commands_rx) = mpsc::unbounded_channel();
let runtime = Arc::new(Mutex::new(ComputerRuntime::new(
"box".to_owned(),
ComputerSpec::default(),
None,
dir.path().to_path_buf(),
)));
let (snapshot_tx, snapshot) = watch::channel(ComputerSnapshot::project(
&runtime.lock().unwrap(),
ComputerState::Starting,
deadlines,
));
let (timers, timers_enabled) = watch::channel(true);
if journal {
let config = RuntimeConfig::default();
let record = ComputerStateRecord::new("box", None, None, None, &config, None).unwrap();
write_state_record(dir.path(), &record).unwrap();
}
let records = Arc::new(ComputerRecordStore::new(dir.path()).unwrap());
let generation = record.then(|| {
match records
.provision_intent(
"box",
"key",
ComputerSpec {
id: Some("box".to_owned()),
..ComputerSpec::default()
},
)
.unwrap()
{
crate::sandbox::record::ProvisionIntent::Created(record) => record.generation,
other => panic!("unexpected intent: {other:?}"),
}
});
let registered = Arc::new(AtomicBool::new(true));
let unregister = Arc::clone(®istered);
let seed = ComputerSeed {
id: "box".to_owned(),
runtime: Arc::clone(&runtime),
unregister: Arc::new(move || unregister.store(false, Ordering::SeqCst)),
generation,
vm_dir: dir.path().to_path_buf(),
records,
events: events_tx,
tasks: Arc::clone(&script) as Arc<dyn ComputerTasks>,
deadlines,
timers_enabled,
seeded: Seeded::Fresh,
};
let actor = tokio::spawn(ComputerActor::new(seed, commands_rx, snapshot_tx).run());
Self {
commands,
registered,
runtime,
snapshot,
events,
script,
actor,
_timers: timers,
dir,
}
}
fn send(&self, command: impl FnOnce(oneshot::Sender<Result<()>>) -> Command) -> Waiter {
let (reply, waiter) = oneshot::channel();
self.commands.send(command(reply)).unwrap();
Waiter(waiter)
}
async fn boot_to_ready(&mut self) {
self.send(|reply| Command::Provision {
provision: Provision::Boot { warm: false },
outcome: ComputerProvisionOutcome::default(),
reply,
})
.ok()
.await;
self.settled(ComputerState::Ready).await;
}
async fn settled(&mut self, state: ComputerState) {
self.snapshot
.wait_for(|snapshot| snapshot.state == state)
.await
.unwrap();
}
async fn joined(&mut self) {
(&mut self.actor).await.unwrap();
}
async fn awaited(&self, call: &str) {
while !self.script.calls().contains(&call) {
tokio::task::yield_now().await;
}
}
fn has_journal(&self) -> bool {
self.dir.path().join("state.json").exists()
}
async fn next_event(&mut self, action: &str) -> ComputerEvent {
loop {
let event = self.events.recv().await.expect("the event bus stays open");
if event.action == action {
return event;
}
}
}
fn actions(&mut self) -> Vec<String> {
let mut actions = Vec::new();
while let Ok(event) = self.events.try_recv() {
actions.push(event.action);
}
actions
}
}
struct Waiter(oneshot::Receiver<Result<()>>);
impl Waiter {
async fn ok(self) {
self.0.await.unwrap().unwrap();
}
async fn error(self) -> ComputerError {
self.0.await.unwrap().unwrap_err()
}
}
async fn agent() -> Arc<dyn GuestAgent> {
let dir = tempfile::tempdir().unwrap();
let driver = FakeDriver::new();
let id = VmId::new("agent").unwrap();
let prepared = driver
.prepare()
.unwrap()
.prepare(&id, &IsolationSpec::None, dir.path())
.await
.unwrap();
let handle = Arc::from(
prepared
.boot(VmSpec {
id: id.clone(),
cpus: 1,
memory_mib: 128,
boot: BootSpec::Kernel {
image: "/vmlinux".into(),
cmdline: String::new(),
initrd: None,
},
disks: vec![],
nics: vec![],
vsock: None,
shares: vec![],
console: ConsoleSpec::Off,
balloon: false,
entropy: false,
dirty_tracking: false,
isolation: IsolationSpec::None,
})
.await
.unwrap(),
);
FakeAgentFactory::new().connect(handle, None).unwrap()
}
fn no_deadlines() -> Deadlines {
Deadlines::default()
}
#[tokio::test(start_paused = true)]
async fn a_force_remove_preempts_a_boot_that_has_handed_its_resources_over() {
let mut harness = Harness::start(Boot::HandsOffThenHangs, no_deadlines()).await;
harness
.send(|reply| Command::Provision {
provision: Provision::Boot { warm: false },
outcome: ComputerProvisionOutcome::default(),
reply,
})
.ok()
.await;
harness
.send(|reply| Command::Remove { force: true, reply })
.ok()
.await;
harness.joined().await;
assert_eq!(harness.script.calls(), vec!["boot", "release"]);
assert!(harness.actions().contains(&"removed".to_owned()));
}
#[tokio::test(start_paused = true)]
async fn a_teardown_stalls_until_the_boot_hands_its_resources_over() {
let handoff_at = Duration::from_secs(25);
let mut harness = Harness::start(Boot::HandsOffAfter(handoff_at), no_deadlines()).await;
harness
.send(|reply| Command::Provision {
provision: Provision::Boot { warm: false },
outcome: ComputerProvisionOutcome::default(),
reply,
})
.ok()
.await;
let started = tokio::time::Instant::now();
harness
.send(|reply| Command::Remove { force: true, reply })
.ok()
.await;
harness.joined().await;
assert!(
started.elapsed() >= handoff_at,
"the teardown ran before the handoff landed"
);
assert_eq!(harness.script.calls(), vec!["boot", "release"]);
}
#[tokio::test(start_paused = true)]
async fn a_superseded_tasks_completion_cannot_drive_the_machine() {
let mut harness = Harness::start(Boot::HandsOffAndCompletes, no_deadlines()).await;
let created = harness.send(|reply| Command::Provision {
provision: Provision::Boot { warm: false },
outcome: ComputerProvisionOutcome::default(),
reply,
});
let removed = harness.send(|reply| Command::Remove { force: true, reply });
created.ok().await;
removed.ok().await;
harness.joined().await;
let actions = harness.actions();
assert!(!actions.contains(&"ready".to_owned()), "{actions:?}");
assert!(!harness.script.calls().contains(&"gate"));
}
#[tokio::test(start_paused = true)]
async fn a_stop_during_a_launch_is_served_once_the_launch_lands() {
let mut harness = Harness::start(Boot::Completes, no_deadlines()).await;
let created = harness.send(|reply| Command::Provision {
provision: Provision::Boot { warm: false },
outcome: ComputerProvisionOutcome::default(),
reply,
});
let stopped = harness.send(|reply| Command::Stop {
budget: Duration::from_secs(30),
reply,
});
created.ok().await;
stopped.ok().await;
assert_eq!(harness.script.calls(), vec!["boot", "gate", "stop"]);
assert!(harness.snapshot.borrow().agent.is_none());
let actions = harness.actions();
assert_eq!(
actions,
vec!["created", "ready", "stopping", "stopped"],
"{actions:?}"
);
}
#[tokio::test(start_paused = true)]
async fn the_guest_agent_is_published_on_the_snapshot_the_moment_the_boot_reports() {
let mut harness = Harness::start(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
assert!(harness.snapshot.borrow().agent.is_some());
harness
.send(|reply| Command::Remove { force: true, reply })
.ok()
.await;
harness.joined().await;
assert!(harness.snapshot.borrow().agent.is_none());
}
#[tokio::test(start_paused = true)]
async fn the_idle_window_is_the_actors_own_timer() {
let mut harness = Harness::start(
Boot::Completes,
Deadlines {
ttl: None,
idle_timeout_seconds: 30,
on_idle: IdleAction::Kill,
},
)
.await;
harness.boot_to_ready().await;
harness.joined().await;
assert_eq!(harness.script.calls(), vec!["boot", "gate", "release"]);
assert!(harness.actions().contains(&"removed".to_owned()));
}
#[tokio::test(start_paused = true)]
async fn a_failure_keeps_its_crash_journal_when_the_release_fails() {
let mut harness = Harness::journalled(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
assert!(harness.has_journal());
harness.script.release_fails.store(true, Ordering::SeqCst);
harness.commands.send(Command::VmExited).unwrap();
harness.settled(ComputerState::Failed).await;
harness.awaited("release").await;
tokio::task::yield_now().await;
assert!(
harness.has_journal(),
"a failed release must leave the journal naming what it could not free"
);
}
#[tokio::test(start_paused = true)]
async fn a_failure_drops_its_crash_journal_once_the_release_is_done() {
let mut harness = Harness::journalled(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
assert!(harness.has_journal());
harness.commands.send(Command::VmExited).unwrap();
harness.settled(ComputerState::Failed).await;
while harness.has_journal() {
tokio::task::yield_now().await;
}
}
#[tokio::test(start_paused = true)]
async fn a_removal_whose_release_fails_answers_its_caller() {
let mut harness = Harness::start(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
harness.script.release_fails.store(true, Ordering::SeqCst);
let error = harness
.send(|reply| Command::Remove { force: true, reply })
.error()
.await;
assert!(
error.to_string().contains("the vmm would not die"),
"{error}"
);
harness.script.release_fails.store(false, Ordering::SeqCst);
harness
.send(|reply| Command::Remove { force: true, reply })
.ok()
.await;
harness.joined().await;
assert!(harness.actions().contains(&"removed".to_owned()));
}
#[tokio::test(start_paused = true)]
async fn a_panicked_sub_task_is_reported_rather_than_swallowed() {
let harness = Harness::start(Boot::HandsOffThenPanics, no_deadlines()).await;
harness.send(|reply| Command::Provision {
provision: Provision::Boot { warm: false },
outcome: ComputerProvisionOutcome::default(),
reply,
});
let error = harness
.send(|reply| Command::Remove { force: true, reply })
.error()
.await;
assert!(error.to_string().contains("panicked"), "{error}");
assert!(!harness.script.calls().contains(&"release"));
harness
.send(|reply| Command::Remove { force: true, reply })
.ok()
.await;
assert!(harness.script.calls().contains(&"release"));
}
#[tokio::test(start_paused = true)]
async fn a_boot_that_never_handed_off_is_joined_so_its_own_cleanup_finishes() {
let harness = Harness::start(Boot::DropsHandoffThenCleansUp, no_deadlines()).await;
harness.send(|reply| Command::Provision {
provision: Provision::Boot { warm: false },
outcome: ComputerProvisionOutcome::default(),
reply,
});
harness.awaited("boot").await;
tokio::task::yield_now().await;
harness
.send(|reply| Command::Remove { force: true, reply })
.ok()
.await;
assert!(
harness.script.cleanup_finished.load(Ordering::SeqCst),
"the boot's own cleanup was cut short"
);
}
#[tokio::test(start_paused = true)]
async fn a_restore_is_joined_rather_than_aborted() {
let harness = Harness::start(Boot::Completes, no_deadlines()).await;
*harness.script.restore_takes.lock().unwrap() = Some(Duration::from_secs(3));
harness.send(|reply| Command::Provision {
provision: Provision::Restore {
origin: RestoreOrigin::Restore,
},
outcome: ComputerProvisionOutcome::default(),
reply,
});
tokio::task::yield_now().await;
harness
.send(|reply| Command::Remove { force: true, reply })
.ok()
.await;
assert!(
harness.script.restore_finished.load(Ordering::SeqCst),
"the restore was aborted instead of joined"
);
}
#[tokio::test(start_paused = true)]
async fn a_stop_during_the_gates_own_cmd_is_still_deferred() {
let mut harness = Harness::start(Boot::Completes, no_deadlines()).await;
*harness.script.gate_takes.lock().unwrap() = Some(Duration::from_secs(5));
harness
.send(|reply| Command::Provision {
provision: Provision::Boot { warm: false },
outcome: ComputerProvisionOutcome::default(),
reply,
})
.ok()
.await;
harness.awaited("gate").await;
harness
.send(|reply| Command::ClaimWorkload {
claim: crate::sandbox::workload::WorkloadClaim::Initial,
reply,
})
.ok()
.await;
harness.settled(ComputerState::Running).await;
let stopped = harness.send(|reply| Command::Stop {
budget: Duration::from_secs(30),
reply,
});
stopped.ok().await;
assert!(harness.script.calls().contains(&"stop"));
}
#[tokio::test(start_paused = true)]
async fn a_claim_the_machine_refuses_is_answered_wrong_state() {
let mut harness = Harness::start(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
harness
.send(|reply| Command::ClaimWorkload {
claim: crate::sandbox::workload::WorkloadClaim::Api,
reply,
})
.ok()
.await;
let error = harness
.send(|reply| Command::ClaimWorkload {
claim: crate::sandbox::workload::WorkloadClaim::Api,
reply,
})
.error()
.await;
assert!(matches!(error, ComputerError::WrongState { .. }), "{error}");
let error = harness
.send(|reply| Command::Remove {
force: false,
reply,
})
.error()
.await;
assert!(matches!(error, ComputerError::WrongState { .. }), "{error}");
}
#[tokio::test(start_paused = true)]
async fn a_failed_restore_answers_only_once_its_teardown_has_freed_the_id() {
let harness = Harness::start(Boot::Completes, no_deadlines()).await;
harness.script.restore_fails.store(true, Ordering::SeqCst);
let restoring = harness.send(|reply| Command::Provision {
provision: Provision::Restore {
origin: RestoreOrigin::WarmCreate,
},
outcome: ComputerProvisionOutcome::default(),
reply,
});
let error = restoring.error().await;
assert!(
error.to_string().contains("checkpoint would not load"),
"the caller hears the restore's own failure: {error}"
);
assert_eq!(
harness.script.calls(),
vec!["restore", "release"],
"the teardown ran before the caller was told"
);
}
#[tokio::test(start_paused = true)]
async fn a_paused_computer_records_what_it_retained() {
let mut harness = Harness::start(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
let paused = harness.send(|reply| Command::Pause {
reason: PauseReason::Requested,
reply,
});
paused.ok().await;
{
let runtime = harness.runtime.lock().unwrap();
assert_eq!(runtime.pause_snapshot_id.as_deref(), Some("snap"));
assert!(
runtime.paused_at.is_some(),
"a paused computer reports when it went to sleep"
);
}
let snapshot = harness.snapshot.borrow();
assert_eq!(snapshot.state, ComputerState::Paused);
assert_eq!(snapshot.pause_snapshot_id.as_deref(), Some("snap"));
assert!(snapshot.paused_at.is_some());
}
#[tokio::test(start_paused = true)]
async fn an_idle_pause_says_so_on_its_event() {
let mut harness = Harness::start(
Boot::Completes,
Deadlines {
ttl: None,
idle_timeout_seconds: 2,
on_idle: IdleAction::Pause,
},
)
.await;
harness.boot_to_ready().await;
let pausing = harness
.next_event(crate::sandbox::types::action::PAUSING)
.await;
assert_eq!(
pausing.attributes.get("reason").map(String::as_str),
Some(crate::sandbox::pause_reason::IDLE_TIMEOUT)
);
}
#[tokio::test(start_paused = true)]
async fn a_record_that_will_not_move_stalls_the_teardown() {
let mut harness = Harness::recorded(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
let record_path = harness.dir.path().join("sandbox-records").join("box.json");
let record = std::fs::read(&record_path).unwrap();
std::fs::remove_file(&record_path).unwrap();
std::fs::create_dir(&record_path).unwrap();
let refused = harness
.send(|reply| Command::Remove { force: true, reply })
.error()
.await;
assert!(
refused.to_string().contains("recording removing failed"),
"the caller hears the write that was refused: {refused}"
);
assert!(
harness.registered.load(Ordering::SeqCst),
"the computer stays where a retry can reach it"
);
assert!(
!harness
.actions()
.contains(&crate::sandbox::types::action::REMOVED.to_owned()),
"nothing announces a removal that did not happen"
);
std::fs::remove_dir(&record_path).unwrap();
std::fs::write(&record_path, record).unwrap();
harness
.send(|reply| Command::Remove { force: true, reply })
.ok()
.await;
assert!(!harness.registered.load(Ordering::SeqCst));
harness.joined().await;
}
#[tokio::test(start_paused = true)]
async fn a_removal_parked_on_its_record_is_not_answered_done() {
let mut harness = Harness::recorded(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
let record_path = harness.dir.path().join("sandbox-records").join("box.json");
*harness.script.release_takes.lock().unwrap() = Some(Duration::from_secs(1));
let removing = harness.send(|reply| Command::Remove { force: true, reply });
tokio::time::sleep(Duration::from_millis(100)).await;
let record = std::fs::read(&record_path).unwrap();
std::fs::remove_file(&record_path).unwrap();
std::fs::create_dir(&record_path).unwrap();
tokio::time::sleep(Duration::from_secs(2)).await;
let second = harness.send(|reply| Command::Remove { force: true, reply });
tokio::time::sleep(Duration::from_millis(400)).await;
assert!(
harness.registered.load(Ordering::SeqCst),
"the id is still owned by a record that would not delete"
);
std::fs::remove_dir(&record_path).unwrap();
std::fs::write(&record_path, record).unwrap();
tokio::time::sleep(Duration::from_secs(10)).await;
removing.ok().await;
second.ok().await;
assert!(!harness.registered.load(Ordering::SeqCst));
}
#[tokio::test(start_paused = true)]
async fn a_preempted_checkpoint_answers_its_caller() {
let mut harness = Harness::start(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
*harness.script.checkpoint_takes.lock().unwrap() = Some(Duration::from_secs(30));
let (reply, capture) = oneshot::channel();
harness
.commands
.send(Command::Checkpoint {
spec: CaptureSpec {
name: "snap".to_owned(),
labels: std::collections::HashMap::new(),
},
reply,
})
.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
harness
.send(|reply| Command::Stop {
budget: Duration::from_secs(1),
reply,
})
.ok()
.await;
let answered = tokio::time::timeout(Duration::from_secs(5), capture)
.await
.expect("the preempted checkpoint answers rather than hanging")
.unwrap();
let error = match answered {
Ok(_) => panic!("a preempted checkpoint cannot report a snapshot"),
Err(error) => error,
};
assert!(error.to_string().contains("preempted"), "{error}");
}
#[tokio::test(start_paused = true)]
async fn a_stop_drains_against_the_marker_its_transition_saw() {
let mut harness = Harness::start(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
harness
.send(|reply| Command::ClaimWorkload {
claim: WorkloadClaim::Api,
reply,
})
.ok()
.await;
let stopping = harness.send(|reply| Command::Stop {
budget: Duration::from_secs(1),
reply,
});
harness
.commands
.send(Command::WorkloadExited {
outcome: WorkloadOutcome::Exited(crate::agent::ExitStatus::Code(0)),
})
.unwrap();
stopping.ok().await;
assert_eq!(harness.script.calls().last().copied(), Some("stop"));
}
#[tokio::test(start_paused = true)]
async fn concurrent_partial_lifecycle_updates_compose() {
let mut harness = Harness::start(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
let ttl_only = harness.send(|reply| Command::SetLifecycle {
update: LifecycleUpdate {
ttl_seconds: Some(600),
..LifecycleUpdate::default()
},
reply,
});
let idle_only = harness.send(|reply| Command::SetLifecycle {
update: LifecycleUpdate {
idle_timeout_seconds: Some(30),
..LifecycleUpdate::default()
},
reply,
});
ttl_only.ok().await;
idle_only.ok().await;
let deadlines = harness.snapshot.borrow().deadlines;
assert!(deadlines.ttl.is_some(), "the TTL-only update survived");
assert_eq!(deadlines.idle_timeout_seconds, 30);
}
#[tokio::test(start_paused = true)]
async fn a_refused_resume_write_abandons_the_resume_it_was_recording() {
let mut harness = Harness::recorded(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
harness
.send(|reply| Command::Pause {
reason: PauseReason::Requested,
reply,
})
.ok()
.await;
let record_path = harness.dir.path().join("sandbox-records").join("box.json");
std::fs::remove_file(&record_path).unwrap();
std::fs::create_dir(&record_path).unwrap();
let error = harness
.send(|reply| Command::Resume {
reason: "resume".to_owned(),
reply,
})
.error()
.await;
assert!(error.to_string().contains("recording"), "{error}");
assert!(
!harness.script.calls().contains(&"resume"),
"the resume the write was recording must not have run"
);
}
#[tokio::test(start_paused = true)]
async fn a_refused_failure_write_still_releases_and_keeps_the_journal() {
let mut harness = Harness::recorded(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
let journal =
ComputerStateRecord::new("box", None, None, None, &RuntimeConfig::default(), None).unwrap();
write_state_record(harness.dir.path(), &journal).unwrap();
let record_path = harness.dir.path().join("sandbox-records").join("box.json");
std::fs::remove_file(&record_path).unwrap();
std::fs::create_dir(&record_path).unwrap();
harness.commands.send(Command::VmExited).unwrap();
harness.settled(ComputerState::Failed).await;
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(
harness.script.calls().contains(&"release"),
"the release runs even though the failure was not recorded"
);
assert!(
harness.has_journal(),
"and the journal stays: nothing proved the failure durable"
);
}
#[tokio::test(start_paused = true)]
async fn a_handover_ordered_during_a_stop_is_refused() {
let mut harness = Harness::start(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
*harness.script.stop_takes.lock().unwrap() = Some(Duration::from_secs(30));
let stopping = harness.send(|reply| Command::Stop {
budget: Duration::from_secs(60),
reply,
});
harness.settled(ComputerState::Stopping).await;
let error = harness
.send(|reply| Command::Detach { reply })
.error()
.await;
assert!(
matches!(error, ComputerError::WrongState { .. }),
"a handover during a teardown must be refused, not raced: {error}"
);
assert!(
!harness.script.calls().contains(&"detach"),
"the port was reached anyway: {:?}",
harness.script.calls()
);
tokio::time::sleep(Duration::from_secs(31)).await;
stopping.ok().await;
}
#[tokio::test(start_paused = true)]
async fn a_teardown_after_a_handover_never_reaches_the_vm() {
let mut harness = Harness::start(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
harness.send(|reply| Command::Detach { reply }).ok().await;
assert!(harness.script.calls().contains(&"detach"));
let stop = harness
.send(|reply| Command::Stop {
budget: Duration::from_secs(1),
reply,
})
.error()
.await;
assert!(
matches!(stop, ComputerError::WrongState { .. }),
"a stop after a handover must be refused: {stop}"
);
let removed = harness
.send(|reply| Command::Remove { force: true, reply })
.error()
.await;
assert!(
matches!(removed, ComputerError::WrongState { .. }),
"a forced remove after a handover must be refused: {removed}"
);
tokio::time::sleep(Duration::from_millis(100)).await;
let calls = harness.script.calls();
assert!(
!calls.contains(&"stop") && !calls.contains(&"release"),
"a handed-over vm was torn down anyway: {calls:?}"
);
harness.send(|reply| Command::Detach { reply }).ok().await;
}
#[tokio::test(start_paused = true)]
async fn a_refused_handover_leaves_the_computer_usable() {
let mut harness = Harness::start(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
harness.script.detach_fails.store(true, Ordering::SeqCst);
let error = harness
.send(|reply| Command::Detach { reply })
.error()
.await;
assert!(
error.to_string().contains("would not be handed over"),
"{error}"
);
assert_eq!(
harness.snapshot.borrow().state,
ComputerState::Ready,
"a refused handover moved the computer"
);
assert!(
harness.snapshot.borrow().agent.is_some(),
"a refused handover left the computer undialable"
);
harness
.send(|reply| Command::Stop {
budget: Duration::from_secs(1),
reply,
})
.ok()
.await;
assert!(harness.script.calls().contains(&"stop"));
}
#[tokio::test(start_paused = true)]
async fn a_handover_takes_the_guest_agent_off_the_snapshot() {
let mut harness = Harness::start(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
assert!(
harness.snapshot.borrow().agent.is_some(),
"a ready computer is dialable"
);
harness.send(|reply| Command::Detach { reply }).ok().await;
assert!(
harness.snapshot.borrow().agent.is_none(),
"the data plane can still reach a handed-over guest"
);
}
#[tokio::test(start_paused = true)]
async fn a_handed_over_computer_refuses_a_lifecycle_update() {
let mut harness = Harness::recorded(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
harness.send(|reply| Command::Detach { reply }).ok().await;
let error = harness
.send(|reply| Command::SetLifecycle {
update: LifecycleUpdate {
ttl_seconds: Some(60),
..LifecycleUpdate::default()
},
reply,
})
.error()
.await;
assert!(
matches!(error, ComputerError::WrongState { .. }),
"a handed-over record was written: {error}"
);
}
#[tokio::test(start_paused = true)]
async fn a_handover_during_a_capture_is_refused() {
let mut harness = Harness::start(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
*harness.script.checkpoint_takes.lock().unwrap() = Some(Duration::from_secs(30));
let (reply, capture) = oneshot::channel();
harness
.commands
.send(Command::Checkpoint {
spec: CaptureSpec {
name: "snap".to_owned(),
labels: std::collections::HashMap::new(),
},
reply,
})
.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
let error = harness
.send(|reply| Command::Detach { reply })
.error()
.await;
let ComputerError::WrongState {
expected, actual, ..
} = &error
else {
panic!("a handover during a capture must be refused: {error}");
};
let actual_word = actual.to_lowercase();
assert!(
!expected
.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.any(|word| word == actual_word),
"the refusal names the state it is refusing: expected {expected}, actual {actual}"
);
assert!(
!harness.script.calls().contains(&"detach"),
"the port was reached while a capture held the handle: {:?}",
harness.script.calls()
);
tokio::time::sleep(Duration::from_secs(31)).await;
capture.await.unwrap().unwrap();
}
#[tokio::test(start_paused = true)]
async fn the_agent_is_gone_before_the_handover_reaches_the_driver() {
let mut harness = Harness::start(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
*harness.script.detach_takes.lock().unwrap() = Some(Duration::from_secs(2));
let detaching = harness.send(|reply| Command::Detach { reply });
tokio::time::sleep(Duration::from_secs(1)).await;
assert_eq!(
harness.script.calls().last().copied(),
Some("detach"),
"the port call has not started yet"
);
assert!(
harness.snapshot.borrow().agent.is_none(),
"the data plane could still dial a vm that is changing hands"
);
tokio::time::sleep(Duration::from_secs(2)).await;
detaching.ok().await;
}
#[tokio::test(start_paused = true)]
async fn a_handover_is_not_answered_by_an_earlier_flows_failure() {
let mut harness = Harness::recorded(
Boot::Completes,
Deadlines {
ttl: None,
idle_timeout_seconds: 2,
on_idle: IdleAction::Pause,
},
)
.await;
harness.boot_to_ready().await;
let record_path = harness.dir.path().join("sandbox-records").join("box.json");
let mut record: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&record_path).unwrap()).unwrap();
assert_eq!(record["phase"], "ready");
record["phase"] = serde_json::Value::String("starting".to_owned());
std::fs::write(&record_path, serde_json::to_string(&record).unwrap()).unwrap();
tokio::time::sleep(Duration::from_secs(3)).await;
assert!(
!harness.script.calls().contains(&"checkpoint"),
"the pause the refused write was recording must not have run"
);
assert_eq!(harness.snapshot.borrow().state, ComputerState::Ready);
harness.send(|reply| Command::Detach { reply }).ok().await;
assert!(
harness.script.calls().contains(&"detach"),
"the handover did not reach the driver"
);
assert_eq!(harness.snapshot.borrow().state, ComputerState::Ready);
}
#[tokio::test(start_paused = true)]
async fn a_handover_that_does_not_return_fails_the_handover_alone() {
let mut harness = Harness::start(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
*harness.script.detach_takes.lock().unwrap() = Some(Duration::from_secs(600));
let error = harness
.send(|reply| Command::Detach { reply })
.error()
.await;
assert!(error.to_string().contains("did not finish"), "{error}");
assert_eq!(
harness.snapshot.borrow().state,
ComputerState::Ready,
"a handover that timed out moved the computer"
);
assert!(
harness.snapshot.borrow().agent.is_some(),
"a handover that timed out left the computer undialable"
);
harness
.send(|reply| Command::Stop {
budget: Duration::from_secs(1),
reply,
})
.ok()
.await;
assert!(harness.script.calls().contains(&"stop"));
}
#[tokio::test(start_paused = true)]
async fn a_handed_over_computer_answers_nothing_but_another_handover() {
async fn refused(waiter: Waiter, verb: &str) {
let error = waiter.error().await;
assert!(
matches!(error, ComputerError::WrongState { .. }),
"{verb} was accepted by a handed-over computer: {error}"
);
}
let mut harness = Harness::recorded(Boot::Completes, no_deadlines()).await;
harness.boot_to_ready().await;
harness.send(|reply| Command::Detach { reply }).ok().await;
refused(
harness.send(|reply| Command::Resume {
reason: "test".to_owned(),
reply,
}),
"resume",
)
.await;
refused(
harness.send(|reply| Command::Pause {
reason: PauseReason::Requested,
reply,
}),
"pause",
)
.await;
refused(
harness.send(|reply| Command::ClaimWorkload {
claim: WorkloadClaim::Api,
reply,
}),
"claim",
)
.await;
refused(
harness.send(|reply| Command::SetLifecycle {
update: LifecycleUpdate {
ttl_seconds: Some(60),
..LifecycleUpdate::default()
},
reply,
}),
"set-lifecycle",
)
.await;
refused(
harness.send(|reply| Command::Stop {
budget: Duration::from_secs(1),
reply,
}),
"stop",
)
.await;
refused(
harness.send(|reply| Command::Remove { force: true, reply }),
"remove",
)
.await;
let (reply, capture) = oneshot::channel();
harness
.commands
.send(Command::Checkpoint {
spec: CaptureSpec {
name: "snap".to_owned(),
labels: std::collections::HashMap::new(),
},
reply,
})
.unwrap();
assert!(matches!(
capture.await.unwrap().unwrap_err(),
ComputerError::WrongState { .. }
));
let calls = harness.script.calls();
assert!(
!calls.contains(&"stop") && !calls.contains(&"release") && !calls.contains(&"checkpoint"),
"a handed-over vm was driven anyway: {calls:?}"
);
harness.send(|reply| Command::Detach { reply }).ok().await;
}