crates/monetize-product-wasm/tests/a_guest_is_replaced_live.rs
//! **A guest is replaced while it serves** — no restart, no rebuild: the new
//! module passes every gate a start does, is compiled before anything is
//! swapped, and calls already in flight finish on the old one. A refused
//! replacement (a bad signature, bytes nobody pinned) leaves the old guest
//! serving.
mod common;
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
use common::*;
use guest_host::verify::sign;
use monetize_product::GuestSpec;
use monetize_product_wasm::{GuestProduct, HotGuest};
/// One call into the guest: its plan (op 91), which every toy answers.
fn plan(g: &GuestProduct) -> Result<serde_json::Value, monetize_product::ProductError> {
g.lifecycle_lay_plan(&monetize_product_guest::wire::LayPlanReq { company: "t".into(), ..Default::default() })
}
/// The toy guest with a custom section appended: a valid module, the same
/// behaviour, different bytes — so a different digest, which is what a
/// rebuilt guest is to the gate.
fn toy_variant(dir: &Path, name: &str, tag: &str) -> std::path::PathBuf {
let mut bytes = std::fs::read(built_toy()).unwrap();
let mut content = vec![tag.len() as u8];
content.extend_from_slice(tag.as_bytes());
content.extend_from_slice(b"hot-load");
assert!(content.len() < 128, "one-byte LEB128 size");
bytes.push(0); // custom section
bytes.push(content.len() as u8);
bytes.extend_from_slice(&content);
let p = dir.join(name);
std::fs::write(&p, bytes).unwrap();
p
}
#[test]
fn a_replacement_swaps_live_and_a_call_in_flight_finishes_on_the_old_guest() {
let dir = scratch("hot-swap");
let v1 = toy_copy(&dir);
let spec1 = toy_spec(&v1);
let hot = HotGuest::build(&build("toy", &spec1)).unwrap();
let a = digest_of(&v1);
assert_eq!(hot.current().digest_hex(), a);
assert!(hot.is_current_for(&build("toy", &spec1)).unwrap(), "an unchanged block is not a change");
// A call that has already taken its instance — what `current()` hands every verb.
let in_flight = hot.current();
let v2 = toy_variant(&dir, "toy-v2.wasm", "v2");
let spec2 = toy_spec(&v2);
let b = digest_of(&v2);
assert_ne!(a, b);
assert!(!hot.is_current_for(&build("toy", &spec2)).unwrap(), "new bytes are a change");
let r = hot.replace(&build("toy", &spec2)).unwrap();
assert_eq!((r.id, r.from_digest.as_str(), r.to_digest.as_str()), ("toy", a.as_str(), b.as_str()));
// New calls go to the new guest; the one in flight is still the old one, and still answers.
assert_eq!(hot.current().digest_hex(), b);
assert_eq!(in_flight.digest_hex(), a);
assert!(plan(&in_flight).is_ok());
assert!(plan(&hot.current()).is_ok());
assert!(hot.is_current_for(&build("toy", &spec2)).unwrap());
assert_eq!(hot.facts().meters.len(), in_flight.meter_table().len());
// Another id cannot be moved in under this one.
assert!(hot.replace(&build("other", &spec2)).unwrap_err().contains("cannot move a guest to another id"));
let _ = std::fs::remove_dir_all(&dir);
}
/// **Callers never see a gap.** Four threads call through the product while
/// it is replaced back and forth; every call answers.
#[test]
fn calls_keep_answering_while_the_guest_is_replaced_under_them() {
let dir = scratch("hot-busy");
let v1 = toy_copy(&dir);
let v2 = toy_variant(&dir, "toy-v2.wasm", "v2");
let (spec1, spec2) = (toy_spec(&v1), toy_spec(&v2));
let hot = Arc::new(HotGuest::build(&build("toy", &spec1)).unwrap());
let stop = Arc::new(AtomicBool::new(false));
let (ok, failed) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
let callers: Vec<_> = (0..4)
.map(|_| {
let (hot, stop, ok, failed) = (hot.clone(), stop.clone(), ok.clone(), failed.clone());
std::thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
match plan(&hot.current()) {
Ok(_) => ok.fetch_add(1, Ordering::Relaxed),
Err(_) => failed.fetch_add(1, Ordering::Relaxed),
};
}
})
})
.collect();
for i in 0..6 {
let spec = if i % 2 == 0 { &spec2 } else { &spec1 };
hot.replace(&build("toy", spec)).unwrap();
}
stop.store(true, Ordering::Relaxed);
for c in callers {
c.join().unwrap();
}
assert_eq!(hot.current().digest_hex(), digest_of(&v1), "six swaps end where they began");
assert!(ok.load(Ordering::Relaxed) > 0, "no call ran during the swaps");
assert_eq!(failed.load(Ordering::Relaxed), 0, "a call failed while the guest was replaced");
let _ = std::fs::remove_dir_all(&dir);
}
/// **A bad signature is refused, and the old guest keeps serving.** The
/// operator drops a new module and its `.p7s` in place of the old; signed by
/// a key the block's roots do not trust, it is refused by the same gate a
/// start uses — and the running guest never stops answering.
#[test]
fn a_replacement_with_a_bad_signature_is_refused_and_the_old_guest_keeps_serving() {
let dir = scratch("hot-badsig");
let module = toy_copy(&dir);
let (root, trusted) = mint_signer(&dir, "release-door");
let (_, stranger) = mint_signer(&dir, "stranger");
sign::sign_module_file(&module, &trusted).unwrap();
let spec = GuestSpec { module: module.clone(), trust_roots: vec![root], grants: vec!["endpoint".into()], ..Default::default() };
let hot = HotGuest::build(&build("toy", &spec)).unwrap();
let serving = hot.current().digest_hex().to_string();
// The new build, in place, signed by the wrong key.
let v2 = toy_variant(&dir, "staging.wasm", "v2");
std::fs::copy(&v2, &module).unwrap();
sign::sign_module_file(&module, &stranger).unwrap();
assert!(!hot.is_current_for(&build("toy", &spec)).unwrap(), "the bytes on disk changed");
let err = hot.replace(&build("toy", &spec)).unwrap_err();
assert!(err.contains("did not verify") && err.contains("[products.toy]"), "{err}");
assert_eq!(hot.current().digest_hex(), serving, "a refused replacement swapped something in");
assert!(plan(&hot.current()).is_ok(), "the old guest stopped answering");
// No signature at all: the same.
let _ = std::fs::remove_file(dir.join("toy.wasm.p7s"));
assert!(hot.replace(&build("toy", &spec)).unwrap_err().contains("toy.wasm.p7s"));
assert_eq!(hot.current().digest_hex(), serving);
// Signed by the trusted key: accepted, live.
sign::sign_module_file(&module, &trusted).unwrap();
let r = hot.replace(&build("toy", &spec)).unwrap();
assert_eq!((r.from_digest.as_str(), r.to_digest.as_str(), r.signer.as_deref()), (serving.as_str(), digest_of(&v2).as_str(), Some("release-door")));
let _ = std::fs::remove_dir_all(&dir);
}
/// **New bytes under an old pin are refused**: the block still pins the old
/// digest, so the module on disk is not what the operator vouched for.
#[test]
fn new_bytes_under_the_old_pin_are_refused_and_the_old_guest_keeps_serving() {
let dir = scratch("hot-pin");
let module = toy_copy(&dir);
let spec = toy_spec(&module);
let hot = HotGuest::build(&build("toy", &spec)).unwrap();
let serving = hot.current().digest_hex().to_string();
std::fs::copy(toy_variant(&dir, "v2.wasm", "v2"), &module).unwrap();
let err = hot.replace(&build("toy", &spec)).unwrap_err();
assert!(err.contains("not the bytes the operator vouched for"), "{err}");
assert_eq!(hot.current().digest_hex(), serving);
assert!(plan(&hot.current()).is_ok());
let _ = std::fs::remove_dir_all(&dir);
}