host: review fixes for cargo.build, the test runner, the console, and main; tests for every PROTOCOL.md rule

cargo.build and the runner (review T22R): builds use the network again;
core.unlock and core.notify in order under the runner; quiet test
output; manifest preparation keeps [workspace], target tables, renamed
and optional entries; cargo.rpc.discover; -32004 with data.output;
retention of 10 builds; light, empty test worlds with an in-memory
journal; only CARGO_/RUST_/SAUSAGE_ variables; stdin is null; the runner
through --config. A compile cache that cannot be created is not fatal.
Activation replaces plugin.wasm without a gap.

Console and main: init ships the workspace Cargo.lock and creates the
world atomically; each console command runs in its own task, so ps,
stop, and kill work during a slow call; SAUSAGE_HEADLESS; the terminal
mode is restored; signals during boot; chat text is not taken as a
command.

Tests: 20 more tests for PROTOCOL.md sections 2 to 10; the cargo tests
choose the libtest binary from cargo's JSON output and time out.
This commit is contained in:
2026-10-23 07:47:39 +01:00
parent 61d5d5f817
commit a090e1fd57
24 changed files with 1889 additions and 242 deletions
+182 -2
View File
@@ -313,6 +313,24 @@ async fn review_retirement_checks_live_version() {
marker(world.0.join("state/notes/retired"), &previous).await;
host.stop().await;
}
/// T23: an unwritable compile cache does not prevent a world from running.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unwritable_compile_cache_is_nonfatal() {
use std::os::unix::fs::PermissionsExt;
let world = TestWorld::new();
world.plugin("notes", document("add"), document("add"));
let parent = world.0.join("unwritable");
std::fs::create_dir(&parent).unwrap();
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o500)).unwrap();
let result = Host::start_with_cache(&world.0, &parent.join("cache")).await;
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap();
let host = result.unwrap();
assert_eq!(
call(&host, "notes.version", json!({})).await["version"],
"1"
);
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() {
@@ -322,7 +340,7 @@ async fn review_watcher_is_shallow() {
let source = world.0.join("plugins/notes/source");
std::fs::create_dir_all(&source).unwrap();
std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o0)).unwrap();
let result = Host::start(&world.0).await;
let result = Host::start_with_cache(&world.0, &world.0.join("compile-cache")).await;
std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o700)).unwrap();
let host = result.unwrap();
activate(&host, "notes").await.unwrap();
@@ -337,7 +355,7 @@ async fn review_watcher_failure_is_nonfatal() {
let inaccessible = world.0.join("plugins/inaccessible");
std::fs::create_dir_all(&inaccessible).unwrap();
std::fs::set_permissions(&inaccessible, std::fs::Permissions::from_mode(0o0)).unwrap();
let result = Host::start(&world.0).await;
let result = Host::start_with_cache(&world.0, &world.0.join("compile-cache")).await;
std::fs::set_permissions(&inaccessible, std::fs::Permissions::from_mode(0o700)).unwrap();
let host = result.unwrap();
assert!(
@@ -785,3 +803,165 @@ async fn does_not_restart_detached_call() {
);
host.stop().await;
}
/// PROTOCOL 7: activation records the plugin's upgrade grace period in the journal.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn upgrades_enter_the_journal() {
let world = TestWorld::new();
let mut old = document("hold");
old.as_object_mut().unwrap().remove("x-sausage-grace");
world.plugin("notes", old, document("hold"));
let host = world.start().await;
call(&host, "notes.hold", json!({})).await;
activate(&host, "notes").await.unwrap();
let entries = tokio::time::timeout(Duration::from_secs(10), async {
loop {
let entries = call(
&host,
"core.log",
json!({"plugin":"notes","grep":"upgrade grace"}),
)
.await;
if entries
.as_array()
.unwrap()
.iter()
.any(|e| e["line"] == "upgrade grace 60 seconds")
{
break entries;
}
tokio::task::yield_now().await;
}
})
.await
.expect("the upgrade journal entry must arrive within 10 seconds");
assert!(
entries
.as_array()
.unwrap()
.iter()
.all(|e| e["stream"] == "core")
);
host.stop().await;
}
/// PROTOCOL 10.2 and 10.3: breaking endpoint methods reject activation with counterexamples where possible.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn endpoint_contracts_and_activation_counterexamples() {
let method = json!({"name":"stats","params":[{"name":"value","required":true,"schema":{"type":"integer","minimum":0}}],"result":{"schema":{"type":"string"}}});
let expected = json!({"methods":[],"x-sausage-endpoints":{"control":{"methods":[method]}}});
for case in ["missing", "params", "result", "unsupported"] {
let world = TestWorld::new();
let mut next = expected.clone();
match case {
"missing" => next["x-sausage-endpoints"]["control"]["methods"] = json!([]),
"params" => {
next["x-sausage-endpoints"]["control"]["methods"][0]["params"][0]["schema"]["minimum"] =
json!(1)
}
"result" => {
next["x-sausage-endpoints"]["control"]["methods"][0]["result"]["schema"] =
json!({"type":"integer"})
}
_ => {
next["x-sausage-endpoints"]["control"]["methods"][0]["result"]["schema"]["pattern"] =
json!("[a-z]+")
}
}
world.plugin("notes", expected.clone(), next);
world.plugin(
"agent",
consumer("notes", expected.clone()),
document("consume"),
);
let host = world.start().await;
let error = activate(&host, "notes").await.unwrap_err();
assert_eq!(error["code"], -32003, "{case}: {error}");
assert!(!error["data"].as_array().unwrap().is_empty());
if ["params", "result"].contains(&case) {
assert!(
error["data"]
.as_array()
.unwrap()
.iter()
.any(|p| !p["counterexample"].is_null()),
"{error}"
);
}
if case == "unsupported" {
assert!(error["data"].to_string().contains("unsupported"), "{error}");
}
assert_eq!(
std::fs::read(world.0.join("plugins/notes/plugin.wasm")).unwrap(),
std::fs::read(fixture(1)).unwrap()
);
host.stop().await;
}
}
/// PROTOCOL 10.2: providers can add methods, optional params, and result fields without breaking consumers.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn additive_contract_changes_activate() {
let old = json!({"methods":[{"name":"read","params":[],"result":{"schema":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}}}]});
let mut next = old.clone();
next["methods"][0]["params"] =
json!([{"name":"limit","required":false,"schema":{"type":"integer"}}]);
next["methods"][0]["result"]["schema"]["properties"]["extra"] = json!({"type":"boolean"});
next["methods"]
.as_array_mut()
.unwrap()
.push(json!({"name":"new","params":[],"result":{"schema":{"type":"null"}}}));
let world = TestWorld::new();
world.plugin("notes", old.clone(), next);
world.plugin("agent", consumer("notes", old), document("consume"));
let host = world.start().await;
activate(&host, "notes").await.unwrap();
host.stop().await;
}
/// PROTOCOL 3.7: an old consumer can still send its old import during grace, even after the provider removes it.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn old_consumer_can_call_removed_provider_method() {
let world = TestWorld::new();
world.plugin("notes", document("legacy"), document("new"));
let mut old = consumer("notes", document("legacy"));
old["x-sausage-grace"] = json!(60);
world.plugin("agent", old, document("consume"));
let host = world.start().await;
let pid = call(&host, "agent.deferred", json!({})).await;
activate(&host, "agent").await.unwrap();
activate(&host, "notes").await.unwrap();
assert_eq!(host.processes()[pid.as_str().unwrap()].state, "running");
std::fs::write(world.0.join("state/agent/release-call"), "release").unwrap();
let response: Value = tokio::time::timeout(Duration::from_secs(10), async {
loop {
if let Ok(bytes) = std::fs::read(world.0.join("state/agent/call-result"))
&& let Ok(value) = serde_json::from_slice(&bytes)
{
break value;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert_eq!(response["error"]["code"], -32601);
assert_eq!(response["error"]["data"]["version"], "2");
host.stop().await;
}
/// PROTOCOL 10.2: activation and revert check interfaces but leave persistent state unchanged.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn state_is_outside_interface_checks() {
let world = TestWorld::new();
world.plugin("notes", document("add"), document("add"));
let state = world.0.join("state/notes/user-state");
let contents = b"a state format neither version understands\0";
std::fs::write(&state, contents).unwrap();
let host = world.start().await;
activate(&host, "notes").await.unwrap();
assert_eq!(std::fs::read(&state).unwrap(), contents);
call(&host, "core.revert", json!({"plugin":"notes"})).await;
assert_eq!(std::fs::read(state).unwrap(), contents);
host.stop().await;
}