core: review fixes, init builds the agent, console without a terminal
Activation path (review T6R): a broken plugin or a stray file in /plugins blocks no activation; candidates replace their own entries; discovery has a 10 s deadline and its process is stopped after the answer; an unchanged hash keeps plugin.prev.wasm; activations run in their own task; the watcher is not recursive; no duplicate services; rpc.retired waits for live processes of its version; reads and hashes on blocking threads. Guest I/O and per-call paths: a stdin write error is EOF, stdout and stderr are drained before the status; stderr is lossy UTF-8 and goes to the journal from its own reader; compiled components are freed; shutdown starts no new processes; lock queues drop stopping processes; compile before the admission gate; CommandPre per component; core.unlock in order; null errors are successes; the journal thread survives SQLite errors. sausage init builds the agent with cargo.build; sausage without a command creates ./world. The console reads plain lines without a terminal. PROTOCOL.md and DECISIONS.md describe the decided rules. The flake tests the whole workspace and checks the packaged cargo.build.
This commit is contained in:
+299
-3
@@ -7,7 +7,7 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
/// Builds two genuine guest versions in this executor's target folder.
|
||||
/// Builds genuine guest versions in this executor's target folder.
|
||||
fn fixture(version: usize) -> PathBuf {
|
||||
static FILES: OnceLock<Vec<PathBuf>> = OnceLock::new();
|
||||
FILES.get_or_init(|| {
|
||||
@@ -16,7 +16,7 @@ fn fixture(version: usize) -> PathBuf {
|
||||
"target/compat/activation-guests/{}",
|
||||
std::process::id()
|
||||
));
|
||||
(1..=2)
|
||||
(1..=3)
|
||||
.map(|version| {
|
||||
let status = std::process::Command::new("cargo")
|
||||
.env("FIXTURE_VERSION", version.to_string())
|
||||
@@ -132,6 +132,302 @@ async fn graph(host: &Host) -> Value {
|
||||
call(host, "notes.read", json!({"path":"/proc/core/graph.json"})).await
|
||||
}
|
||||
|
||||
/// H1/H2: broken providers and stray files do not block repair, revert, or force.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_repairs_with_broken_entries() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
world.plugin("broken", document("bad"), document("bad"));
|
||||
std::fs::write(world.0.join("plugins/broken/plugin.wasm"), b"broken").unwrap();
|
||||
let host = world.start().await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
assert!(!graph(&host).await["plugins"]["broken"]["error"].is_null());
|
||||
std::fs::copy(fixture(1), world.0.join("plugins/broken/plugin.prev.wasm")).unwrap();
|
||||
call(&host, "core.revert", json!({"plugin":"broken"})).await;
|
||||
std::fs::write(world.0.join("plugins/broken/plugin.wasm"), b"broken again").unwrap();
|
||||
activate(&host, "broken").await.unwrap();
|
||||
std::fs::write(world.0.join("plugins/broken/plugin.wasm"), b"broken again").unwrap();
|
||||
std::fs::write(
|
||||
world.0.join("state/notes/2.json"),
|
||||
consumer("broken", document("bad")).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
call(
|
||||
&host,
|
||||
"core.activate",
|
||||
json!({"plugin":"notes","wasm":"/state/notes/next.wasm","force":true}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
graph(&host).await["plugins"]["notes"]["imports"]["broken"]["status"],
|
||||
"unavailable"
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// H2: a stray file under plugins does not interrupt discovery or activation.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_stray_file() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
std::fs::write(world.0.join("plugins/notes.md"), "a note").unwrap();
|
||||
let host = world.start().await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
assert!(graph(&host).await["plugins"]["notes"]["error"].is_null());
|
||||
host.stop().await;
|
||||
}
|
||||
/// M4: an answered discovery receives EOF instead of leaking a detached process.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_discovery_answer_stops() {
|
||||
let world = TestWorld::new();
|
||||
let mut next = document("add");
|
||||
next["x-test-linger"] = json!(true);
|
||||
world.plugin("notes", document("add"), next);
|
||||
let host = world.start().await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
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)]
|
||||
async fn review_candidate_is_not_old_version() {
|
||||
use sha2::{Digest, Sha256};
|
||||
let world = TestWorld::new();
|
||||
let mut rejected = consumer("missing", document("add"));
|
||||
rejected["x-test-linger"] = json!(true);
|
||||
rejected["x-test-stubborn"] = json!(true);
|
||||
world.plugin("notes", document("add"), rejected);
|
||||
std::fs::write(
|
||||
world.0.join("state/notes/3.json"),
|
||||
document("add").to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::copy(fixture(3), world.0.join("state/notes/third.wasm")).unwrap();
|
||||
let host = world.start().await;
|
||||
assert!(activate(&host, "notes").await.is_err());
|
||||
let candidate = host
|
||||
.processes()
|
||||
.into_iter()
|
||||
.find(|(_, process)| process.method == "rpc.discover")
|
||||
.unwrap()
|
||||
.0;
|
||||
call(
|
||||
&host,
|
||||
"core.activate",
|
||||
json!({"plugin":"notes","wasm":"/state/notes/third.wasm"}),
|
||||
)
|
||||
.await;
|
||||
call(&host, "core.kill", json!({"pid":candidate})).await;
|
||||
let old = format!("{:x}", Sha256::digest(std::fs::read(fixture(1)).unwrap()));
|
||||
marker(world.0.join("state/notes/retired"), &old).await;
|
||||
let rejected_hash = format!("{:x}", Sha256::digest(std::fs::read(fixture(2)).unwrap()));
|
||||
assert!(
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(300),
|
||||
marker(world.0.join("state/notes/retired"), &rejected_hash)
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// M6: shutdown suppresses service restarts and retirement hooks from pending upgrades.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_shutdown_suppresses_hooks() {
|
||||
let world = TestWorld::new();
|
||||
let mut old = document("serve");
|
||||
old["x-sausage-grace"] = json!(60);
|
||||
world.plugin("notes", old, document("serve"));
|
||||
std::fs::write(
|
||||
world.0.join("config/boot.json"),
|
||||
json!([{"method":"notes.serve","params":{}}]).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let host = world.start().await;
|
||||
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!(!world.0.join("state/notes/retired").exists());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(world.0.join("state/notes/service")).unwrap(),
|
||||
"1"
|
||||
);
|
||||
assert!(
|
||||
call(&host, "core.log", json!({"grep":"retired start"}))
|
||||
.await
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
call(&host, "core.log", json!({"grep":"service start"}))
|
||||
.await
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
/// L15: retirement waits for new admissions of a reverted version, not just its first snapshot.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_retirement_checks_live_version() {
|
||||
let world = TestWorld::new();
|
||||
let mut old = document("hold");
|
||||
old["x-sausage-grace"] = json!(60);
|
||||
world.plugin("notes", old.clone(), old);
|
||||
let host = world.start().await;
|
||||
call(&host, "notes.hold", json!({})).await;
|
||||
let first = host
|
||||
.processes()
|
||||
.into_iter()
|
||||
.find(|(_, p)| p.method == "hold")
|
||||
.unwrap()
|
||||
.0;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
call(&host, "core.revert", json!({"plugin":"notes"})).await;
|
||||
call(&host, "notes.hold", json!({})).await;
|
||||
let second = host
|
||||
.processes()
|
||||
.into_iter()
|
||||
.find(|(pid, p)| p.method == "hold" && *pid != first)
|
||||
.unwrap()
|
||||
.0;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
// The v2 retirement from the revert is unrelated to the pending v1 retirement.
|
||||
let previous = {
|
||||
use sha2::{Digest, Sha256};
|
||||
format!("{:x}", Sha256::digest(std::fs::read(fixture(1)).unwrap()))
|
||||
};
|
||||
call(&host, "core.stop", json!({"pid":first})).await;
|
||||
assert!(
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(300),
|
||||
marker(world.0.join("state/notes/retired"), &previous)
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
call(&host, "core.stop", json!({"pid":second})).await;
|
||||
marker(world.0.join("state/notes/retired"), &previous).await;
|
||||
host.stop().await;
|
||||
}
|
||||
/// L13: source subtrees require no watches, even when the host cannot read them.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_watcher_is_shallow() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
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(&world.0).await;
|
||||
std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let host = result.unwrap();
|
||||
activate(&host, "notes").await.unwrap();
|
||||
host.stop().await;
|
||||
}
|
||||
/// L13: a watcher startup error reaches the journal without preventing host startup.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_watcher_failure_is_nonfatal() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
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(&world.0).await;
|
||||
std::fs::set_permissions(&inaccessible, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let host = result.unwrap();
|
||||
assert!(
|
||||
!call(&host, "core.log", json!({"grep":"watcher failed"}))
|
||||
.await
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
activate(&host, "notes").await.unwrap();
|
||||
host.stop().await;
|
||||
}
|
||||
/// L14: rapid service upgrades never overlap singleton instances.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_singleton_restart() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("serve"), document("serve"));
|
||||
std::fs::write(
|
||||
world.0.join("config/boot.json"),
|
||||
json!([{"method":"notes.serve","params":{}}]).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let host = world.start().await;
|
||||
for _ in 0..128 {
|
||||
activate(&host, "notes").await.unwrap();
|
||||
call(&host, "core.revert", json!({"plugin":"notes"})).await;
|
||||
}
|
||||
host.stop().await;
|
||||
assert!(!world.0.join("state/notes/duplicate-service").exists());
|
||||
}
|
||||
/// H2: a plugin read error belongs to that entry, not the entire graph.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_read_error_is_local() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
std::fs::create_dir_all(world.0.join("plugins/broken/plugin.wasm")).unwrap();
|
||||
let host = world.start().await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
assert!(!graph(&host).await["plugins"]["broken"]["error"].is_null());
|
||||
host.stop().await;
|
||||
}
|
||||
/// L3: cancellation of the caller cannot cancel an activation body.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_dropped_activation_completes() {
|
||||
let world = TestWorld::new();
|
||||
let mut next = document("add");
|
||||
next["x-test-wait"] = json!(true);
|
||||
world.plugin("notes", document("add"), next);
|
||||
let host = world.start().await;
|
||||
let caller = host.clone();
|
||||
let task = tokio::spawn(async move { activate(&caller, "notes").await });
|
||||
marker(world.0.join("state/notes/discover-started"), "ready").await;
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
std::fs::write(world.0.join("state/notes/discover-release"), "ready").unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
while std::fs::read(world.0.join("plugins/notes/plugin.wasm")).unwrap()
|
||||
!= std::fs::read(fixture(2)).unwrap()
|
||||
{
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
host.stop().await;
|
||||
}
|
||||
/// M4: discovery times out and its process stops after either an answer or a timeout.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_discovery_deadline_and_stop() {
|
||||
let world = TestWorld::new();
|
||||
let mut next = document("add");
|
||||
next["x-test-wait"] = json!(true);
|
||||
world.plugin("notes", document("add"), next);
|
||||
let host = world.start().await;
|
||||
let result = tokio::time::timeout(Duration::from_secs(15), activate(&host, "notes")).await;
|
||||
assert!(
|
||||
result.unwrap().unwrap_err()["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("discovery")
|
||||
);
|
||||
assert!(
|
||||
!host
|
||||
.processes()
|
||||
.values()
|
||||
.any(|process| process.method == "rpc.discover" && process.state == "running")
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 10.4: proc graph files contain valid documents before discovery completes.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn graph_is_valid_immediately() {
|
||||
@@ -321,7 +617,7 @@ async fn serializes_activations() {
|
||||
second.unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(world.0.join("plugins/notes/plugin.prev.wasm")).unwrap(),
|
||||
std::fs::read(fixture(2)).unwrap()
|
||||
std::fs::read(fixture(1)).unwrap()
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user