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:
+315
-1
@@ -53,6 +53,314 @@ async fn call(h: &Host, m: &str, p: Value) -> Value {
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.3: a closed stdin does not discard a response already on stdout.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn closed_stdin_drains_stdout() {
|
||||
let w = TestWorld::new();
|
||||
std::fs::write(w.0.join("state/early"), "answer").unwrap();
|
||||
let h = w.start().await;
|
||||
assert_eq!(
|
||||
call(&h, "probe.echo", json!({"large":"x".repeat(2*1024*1024)})).await,
|
||||
"early"
|
||||
);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.3: failed imports remain process failures, even with initial input in flight.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn failed_imports_preserve_process_status() {
|
||||
let w = TestWorld::new();
|
||||
let path = w.0.join("plugins/probe/plugin.wasm");
|
||||
let mut bytes = std::fs::read(&path).unwrap();
|
||||
for index in 0..bytes.len() - 5 {
|
||||
if &bytes[index..index + 5] == b"wasi:" {
|
||||
bytes[index..index + 5].copy_from_slice(b"fake:");
|
||||
}
|
||||
}
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
let h = w.start().await;
|
||||
let error = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(20),
|
||||
h.call("probe.echo", json!({"large":"x".repeat(2*1024*1024)})),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert_eq!(error["code"], -32002);
|
||||
assert!(error["data"]["status"].as_str().unwrap().contains("fake:"));
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.3: a closed stdin preserves the actual exit status and final stderr.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn closed_stdin_preserves_failure_details() {
|
||||
let w = TestWorld::new();
|
||||
std::fs::write(w.0.join("state/early"), "fail").unwrap();
|
||||
let h = w.start().await;
|
||||
let error = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(20),
|
||||
h.call("probe.echo", json!({"large":"x".repeat(2*1024*1024)})),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert_eq!(error["code"], -32002);
|
||||
// WASIp2 command.run represents a nonzero exit as its unit error result.
|
||||
assert_eq!(error["data"]["status"], "exit 1");
|
||||
assert!(
|
||||
error["data"]["lines"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|line| line["line"] == "last early log")
|
||||
);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 7: invalid stderr bytes do not stop later log lines or the guest.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stderr_uses_lossy_utf8_and_continues() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
let pid = call(&h, "probe.invalid_stderr", json!({})).await;
|
||||
call(&h, "core.stop", json!({"pid":pid})).await;
|
||||
let logs = call(&h, "core.log", json!({"pid":pid})).await;
|
||||
assert!(
|
||||
logs.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|line| line["line"] == "\u{fffd}")
|
||||
);
|
||||
assert!(
|
||||
logs.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|line| line["line"] == "after invalid UTF-8")
|
||||
);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.3 and 7: stderr drains even while a large initial stdin write blocks.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stderr_flood_does_not_block_stdin() {
|
||||
let w = TestWorld::new();
|
||||
std::fs::write(w.0.join("state/early"), "flood").unwrap();
|
||||
let h = w.start().await;
|
||||
let payload = json!({"large":"x".repeat(1024*1024)});
|
||||
assert_eq!(call(&h, "probe.echo", payload.clone()).await, payload);
|
||||
h.stop().await;
|
||||
let db = rusqlite::Connection::open(w.0.join("state/core/journal.sqlite")).unwrap();
|
||||
let count: i64 = db
|
||||
.query_row(
|
||||
"SELECT count(*) FROM journal WHERE line LIKE 'flood %'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(count >= 5000);
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.6: shutdown rejects new processes, including calls after stop returns.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shutdown_rejects_new_processes() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
h.stop().await;
|
||||
assert_eq!(
|
||||
h.call("probe.echo", json!({})).await.unwrap_err()["code"],
|
||||
-32600
|
||||
);
|
||||
assert!(h.processes().is_empty());
|
||||
}
|
||||
|
||||
/// PROTOCOL 6: stdout unlock takes effect before the next result reaches its caller.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stdout_unlock_precedes_result() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
for _ in 0..10 {
|
||||
let pid = call(&h, "probe.unlock_answer", json!({})).await;
|
||||
assert_eq!(
|
||||
call(
|
||||
&h,
|
||||
"core.lock",
|
||||
json!({"path":"ordered-unlock","wait":false})
|
||||
)
|
||||
.await,
|
||||
json!({"held":true})
|
||||
);
|
||||
call(&h, "core.unlock", json!({"path":"ordered-unlock"})).await;
|
||||
call(&h, "core.stop", json!({"pid":pid})).await;
|
||||
}
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.5: a notification-started process has no call to receive progress.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn notification_process_has_no_progress_destination() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
assert_eq!(call(&h, "probe.notification_child", json!({})).await, 0);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.1: an explicit null error does not replace a successful result.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn null_error_is_success() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
assert_eq!(call(&h, "probe.null_error", json!({})).await, 42);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 7: a journal insertion failure does not poison the host or stop later writes.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn journal_recovers_after_sqlite_write_failure() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
let db = rusqlite::Connection::open(w.0.join("state/core/journal.sqlite")).unwrap();
|
||||
db.busy_timeout(std::time::Duration::from_secs(5)).unwrap();
|
||||
db.execute_batch("CREATE TRIGGER reject_writes BEFORE INSERT ON journal BEGIN SELECT RAISE(FAIL,'test failure'); END;").unwrap();
|
||||
assert_eq!(
|
||||
call(&h, "probe.echo", json!({"failed_log":true})).await,
|
||||
json!({"failed_log":true})
|
||||
);
|
||||
call(&h, "core.log", json!({})).await;
|
||||
db.execute_batch("DROP TRIGGER reject_writes;").unwrap();
|
||||
assert_eq!(
|
||||
call(&h, "probe.echo", json!({"recovered":true})).await,
|
||||
json!({"recovered":true})
|
||||
);
|
||||
let entries = call(&h, "core.log", json!({"grep":"recovered"})).await;
|
||||
assert!(!entries.as_array().unwrap().is_empty());
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 4: trailing slashes preserve live proc folders and generated plugin folders.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn proc_directories_accept_trailing_slashes() {
|
||||
use sausage_vfs::{Layer, Open};
|
||||
let w = TestWorld::new();
|
||||
let document = json!({"methods":[]});
|
||||
std::fs::create_dir_all(w.0.join("state/probe")).unwrap();
|
||||
std::fs::write(w.0.join("state/probe/discover.json"), document.to_string()).unwrap();
|
||||
let h = w.start().await;
|
||||
let view = h.filesystem().await.unwrap();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(20), async {
|
||||
loop {
|
||||
if view.list("/proc/core/plugins/probe").await.is_ok() {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let pid = call(&h, "probe.serve", json!({})).await;
|
||||
for path in [
|
||||
"/proc/".to_string(),
|
||||
"/proc/core/".into(),
|
||||
"/proc/core/plugins/".into(),
|
||||
"/proc/core/plugins/probe/".into(),
|
||||
format!("/proc/{}/", pid.as_str().unwrap()),
|
||||
] {
|
||||
assert!(view.stat(&path, true).await.unwrap().directory);
|
||||
view.list(&path).await.unwrap();
|
||||
view.open(
|
||||
&path,
|
||||
Open {
|
||||
directory: true,
|
||||
..Open::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.4: Unix replacements with the same size and mtime use the new component.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn component_identity_includes_inode_and_ctime() {
|
||||
let w = TestWorld::new();
|
||||
let path = w.0.join("plugins/probe/plugin.wasm");
|
||||
let mut bytes = std::fs::read(&path).unwrap();
|
||||
bytes.extend_from_slice(&[0, 2, 1, b'a']);
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
|
||||
let h = w.start().await;
|
||||
let old = call(&h, "probe.serve", json!({})).await;
|
||||
*bytes.last_mut().unwrap() = b'b';
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.unwrap()
|
||||
.set_times(std::fs::FileTimes::new().set_modified(modified))
|
||||
.unwrap();
|
||||
let new = call(&h, "probe.serve", json!({})).await;
|
||||
let table = h.processes();
|
||||
assert_ne!(
|
||||
table[old.as_str().unwrap()].version,
|
||||
table[new.as_str().unwrap()].version
|
||||
);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.6 and 6: stopping cancels a native process's queued lock request immediately.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stopping_removes_queued_lock_acquisitions() {
|
||||
use sausage_vfs::{Layer, Open};
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
call(&h, "core.lock", json!({"path":"queued-stop"})).await;
|
||||
h.register_native(
|
||||
"queued",
|
||||
std::sync::Arc::new(|ctx, _, _| {
|
||||
Box::pin(async move {
|
||||
ctx.notify("ready", json!({"pid":ctx.pid}));
|
||||
ctx.call("core.lock", json!({"path":"queued-stop"})).await
|
||||
})
|
||||
}),
|
||||
);
|
||||
let mut events = h.subscribe();
|
||||
let host = h.clone();
|
||||
let pending = tokio::spawn(async move { host.call("queued.wait", json!({})).await });
|
||||
let event = events.recv().await.unwrap();
|
||||
let pid = event["params"]["params"]["pid"].clone();
|
||||
let view = h.filesystem().await.unwrap();
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||
loop {
|
||||
let f = view
|
||||
.open("/proc/core/locks.json", Open::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let locks: Value = serde_json::from_slice(&f.read(0, 65536).await.unwrap()).unwrap();
|
||||
if locks["queued-stop"]["waiting"] == json!([pid]) {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
h.call("core.stop", json!({"pid":pid})),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(pending.await.unwrap().is_err());
|
||||
call(&h, "core.unlock", json!({"path":"queued-stop"})).await;
|
||||
assert_eq!(
|
||||
call(&h, "core.lock", json!({"path":"queued-stop","wait":false})).await,
|
||||
json!({"held":true})
|
||||
);
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.1: json rpc lines carry results.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn json_rpc_lines_carry_results() {
|
||||
@@ -608,7 +916,13 @@ async fn shutdown_stops_busy_processes_in_parallel_after_five_seconds() {
|
||||
}));
|
||||
}
|
||||
tokio::time::timeout(std::time::Duration::from_secs(20), async {
|
||||
while h.processes().len() != 2 {
|
||||
while h
|
||||
.processes()
|
||||
.values()
|
||||
.filter(|p| p.plugin == "probe" && p.method == "busy")
|
||||
.count()
|
||||
!= 2
|
||||
{
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user