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
+252 -1
View File
@@ -1438,7 +1438,7 @@ async fn core_lifetime_events_enter_the_journal() {
let h = w.start().await;
let stopped = call(&h, "probe.serve", json!({"n":1})).await;
call(&h, "core.stop", json!({"pid":stopped})).await;
let killed = call(&h, "probe.serve", json!({"n":2})).await;
let killed = call(&h, "probe.tree", json!({"depth":0})).await;
call(&h, "core.kill", json!({"pid":killed})).await;
let entries = call(&h, "core.log", json!({"plugin":"probe","limit":100})).await;
let lines: Vec<_> = entries
@@ -1706,3 +1706,254 @@ async fn filesystem_native_processes_and_lock_snapshot() {
assert!(view.stat(&format!("/proc/{pid}"), true).await.is_err());
h.stop().await;
}
/// PROTOCOL 2: invalid plugin names cannot route calls, and core cannot be shadowed.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn plugin_names_and_core_reservation() {
let w = TestWorld::new();
let h = w.start().await;
for name in ["Upper", "bad!", "with space", "é", "", "../probe"] {
h.register_native(
name,
std::sync::Arc::new(|_, _, _| Box::pin(async { Ok(json!("bad")) })),
);
assert_eq!(
h.call(&format!("{name}.echo"), json!({}))
.await
.unwrap_err()["code"],
-32601
);
}
h.register_native(
"core",
std::sync::Arc::new(|_, _, _| Box::pin(async { Ok(json!("bad")) })),
);
assert_eq!(
h.call("core.unknown", json!({})).await.unwrap_err()["code"],
-32601
);
for name in ["a-z_09", "0"] {
h.register_native(
name,
std::sync::Arc::new(|_, _, _| Box::pin(async { Ok(json!(true)) })),
);
assert_eq!(call(&h, &format!("{name}.echo"), json!({})).await, true);
}
h.stop().await;
}
/// PROTOCOL 3.1: a batch on guest stdout does not execute any of its requests.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn batches_are_discarded() {
let w = TestWorld::new();
let h = w.start().await;
assert_eq!(call(&h, "probe.batch", json!({})).await, true);
h.stop().await;
assert!(!w.0.join("state/batch").exists());
assert_eq!(
call(&h, "core.log", json!({"grep":"discarded stdout"}))
.await
.as_array()
.unwrap()
.len(),
1
);
}
/// PROTOCOL 3.3: a second response to an answered call cannot replace its result.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn late_responses_are_dropped() {
let w = TestWorld::new();
let h = w.start().await;
assert_eq!(call(&h, "probe.late_answer", json!({})).await, 42);
assert_eq!(
call(&h, "probe.echo", json!({"next":true})).await,
json!({"next":true})
);
h.stop().await;
}
/// PROTOCOL 3.4: core.call notifications forget detached children after the caller ends.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn notification_core_call_forgets_detached_child() {
let w = TestWorld::new();
let h = w.start().await;
let parent = call(&h, "probe.forget", json!({})).await;
let child: Value = tokio::time::timeout(std::time::Duration::from_secs(10), async {
loop {
if let Ok(bytes) = std::fs::read(w.0.join("state/forgotten"))
&& let Ok(value) = serde_json::from_slice::<Value>(&bytes)
{
break value;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(10), async {
while h.processes().contains_key(parent["pid"].as_str().unwrap()) {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert!(h.processes()[child["pid"].as_str().unwrap()].detached);
assert!(
h.processes()[child["pid"].as_str().unwrap()]
.parent
.is_none()
);
call(&h, "core.stop", json!({"pid":child["pid"]})).await;
assert!(!h.processes().contains_key(child["pid"].as_str().unwrap()));
h.stop().await;
}
/// PROTOCOL 3.6: three generations that ignore EOF stop together within one five-second deadline.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn attached_tree_stops_in_parallel() {
let w = TestWorld::new();
let h = w.start().await;
let root = call(&h, "probe.tree", json!({"depth":2})).await;
assert_eq!(
h.processes()
.values()
.filter(|p| p.method == "tree")
.count(),
3
);
let before = std::time::Instant::now();
call(&h, "core.stop", json!({"pid":root})).await;
tokio::time::timeout(std::time::Duration::from_secs(2), async {
while h.processes().values().any(|p| p.method == "tree") {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert!(before.elapsed() >= std::time::Duration::from_millis(4900));
assert!(before.elapsed() < std::time::Duration::from_secs(7));
h.stop().await;
}
/// PROTOCOL 3.8: a nested call receives its caller's process ID as SAUSAGE_PARENT.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn nested_parent_environment() {
let w = TestWorld::new();
let h = w.start().await;
let result = call(&h, "probe.nested_environment", json!({})).await;
assert_eq!(result["child"]["parent"], result["caller"]);
assert_ne!(result["child"]["pid"], result["caller"]);
h.stop().await;
}
/// PROTOCOL 8 and 3.8: boot services receive core as SAUSAGE_PARENT.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn service_parent_environment() {
let w = TestWorld::new();
std::fs::write(
w.0.join("config/boot.json"),
br#"[{"method":"probe.forgotten","params":{}}]"#,
)
.unwrap();
let h = w.start().await;
let env: Value =
serde_json::from_slice(&std::fs::read(w.0.join("state/forgotten")).unwrap()).unwrap();
assert_eq!(env["parent"], "core");
assert_eq!(env["plugin"], "probe");
assert!(h.processes()[env["pid"].as_str().unwrap()].detached);
h.stop().await;
}
/// PROTOCOL 4: info.json exposes every field of the live process snapshot.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn info_json_has_all_process_fields() {
let w = TestWorld::new();
let h = w.start().await;
let params = json!({"marker":"complete"});
let pid = call(&h, "probe.serve", params.clone()).await;
let text = call(
&h,
"probe.read",
json!({"path":format!("/proc/{}/info.json", pid.as_str().unwrap())}),
)
.await;
let info: Value = serde_json::from_str(text.as_str().unwrap()).unwrap();
let expected = serde_json::to_value(&h.processes()[pid.as_str().unwrap()]).unwrap();
assert_eq!(info, expected);
for field in [
"plugin",
"version",
"method",
"params",
"parent",
"detached",
"start_time",
"state",
] {
assert!(info.get(field).is_some(), "{field}");
}
assert_eq!(info["params"], params);
assert_eq!(info["parent"], "console");
assert_eq!(info["state"], "running");
h.stop().await;
}
/// PROTOCOL 3.6: detached processes persist until stop, kill, or host shutdown explicitly ends them.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn detached_processes_need_explicit_termination() {
let w = TestWorld::new();
let h = w.start().await;
let mut pids = Vec::new();
for _ in 0..3 {
let pid = call(
&h,
"core.call",
json!({"method":"probe.serve","params":{},"detach":true}),
)
.await;
assert!(h.processes()[pid.as_str().unwrap()].parent.is_none());
pids.push(pid);
}
call(&h, "probe.echo", json!({})).await;
for pid in &pids {
assert!(h.processes().contains_key(pid.as_str().unwrap()));
}
call(&h, "core.stop", json!({"pid":pids[0]})).await;
call(&h, "core.kill", json!({"pid":pids[1]})).await;
assert!(h.processes().contains_key(pids[2].as_str().unwrap()));
h.stop().await;
for pid in pids {
assert!(!h.processes().contains_key(pid.as_str().unwrap()));
}
}
/// PROTOCOL 9: core.exit from boot ends the host executable with status 75.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn core_exit_sets_executable_status_75() {
let w = TestWorld::new();
std::fs::write(
w.0.join("config/boot.json"),
br#"[{"method":"core.exit","params":{}}]"#,
)
.unwrap();
let output = tokio::time::timeout(
std::time::Duration::from_secs(30),
tokio::process::Command::new(env!("CARGO_BIN_EXE_sausage"))
.arg("--headless")
.arg("run")
.arg(&w.0)
.stdin(std::process::Stdio::null())
.kill_on_drop(true)
.output(),
)
.await
.unwrap()
.unwrap();
assert_eq!(
output.status.code(),
Some(75),
"{}",
String::from_utf8_lossy(&output.stderr)
);
}