core: sausage-vfs per process (/, /proc, /self, /run), activation, graph, watcher, upgrades

- /proc is a core layer: core/ (PROTOCOL.md, graph.json, graph.dot,
  locks.json, plugins/<name>/discover.json and client.rs) and one folder
  per process (info.json, log, and its /self files).
- Locks are released only after the task ended, the store dropped, and
  in-flight file operations returned.
- core.activate and core.revert with copies, discovery, compatibility
  checks of imports and consumers, groups, force. An admission gate
  makes the snapshot of old processes complete.
- Graph of discover documents by hash, refreshed by a file watcher.
- Upgrades: grace periods (x-sausage-grace), stop, service restarts,
  rpc.retired.
- SDK plugins in the real core (cli/tests/sdk.rs).
This commit is contained in:
2026-10-23 01:05:19 +01:00
parent 151550242b
commit edc2d1a63b
21 changed files with 2306 additions and 101 deletions
+257 -3
View File
@@ -190,7 +190,7 @@ async fn unimplemented_core_methods_return_method_not_found() {
let w = TestWorld::new();
let h = w.start().await;
assert_eq!(
h.call("core.activate", json!({})).await.unwrap_err()["code"],
h.call("core.unknown", json!({})).await.unwrap_err()["code"],
-32601
);
@@ -1083,8 +1083,9 @@ async fn boot_services_are_detached() {
.unwrap();
let h = w.start().await;
let processes = h.processes();
assert_eq!(processes.len(), 1);
assert!(processes.values().next().unwrap().detached);
let services: Vec<_> = processes.values().filter(|p| p.method == "serve").collect();
assert_eq!(services.len(), 1);
assert!(services[0].detached);
h.stop().await;
}
@@ -1110,3 +1111,256 @@ async fn core_lifetime_events_enter_the_journal() {
}
h.stop().await;
}
/// PROTOCOL 4: the root merges world files and mounts, and cross-mount rename fails.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn filesystem_root_and_mount_boundaries() {
let w = TestWorld::new();
let h = w.start().await;
call(&h, "probe.filesystem", json!({"topic":"root"})).await;
assert_eq!(
std::fs::read_to_string(w.0.join("state/root")).unwrap(),
"world"
);
h.stop().await;
}
/// PROTOCOL 4: a process publishes self files read-only under its own live proc folder.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn filesystem_self_and_proc_lifetime() {
use sausage_vfs::Layer;
let w = TestWorld::new();
let h = w.start().await;
let view = h.filesystem().await.unwrap();
let pid = call(&h, "probe.filesystem", json!({"topic":"self"})).await;
call(&h, "core.stop", json!({"pid":pid})).await;
assert!(
view.stat(&format!("/proc/{}", pid.as_str().unwrap()), true)
.await
.is_err()
);
let live = call(&h, "probe.self_serve", json!({})).await;
call(&h, "probe.filesystem", json!({"topic":"isolated"})).await;
assert_eq!(
call(
&h,
"probe.read",
json!({"path":format!("/proc/{}/endpoint",live.as_str().unwrap())})
)
.await,
"endpoint"
);
call(&h, "core.stop", json!({"pid":live})).await;
assert!(
view.stat(&format!("/proc/{}", live.as_str().unwrap()), true)
.await
.is_err()
);
h.stop().await;
}
/// PROTOCOL 4: core-owned self names reject creation and replacement.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn filesystem_reserved_self_names() {
let w = TestWorld::new();
let h = w.start().await;
call(&h, "probe.filesystem", json!({"topic":"reserved"})).await;
h.stop().await;
}
/// PROTOCOL 4: generated logs capture a finite snapshot at open.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn filesystem_log_snapshot_ends() {
let w = TestWorld::new();
let h = w.start().await;
call(&h, "probe.filesystem", json!({"topic":"snapshot"})).await;
h.stop().await;
}
/// PROTOCOL 4: proc/core provides protocol, graph, lock, and plugin inspection files.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn filesystem_core_inspection_files() {
let w = TestWorld::new();
let h = w.start().await;
call(&h, "probe.filesystem", json!({"topic":"core"})).await;
h.stop().await;
}
/// PROTOCOL 4: proc/core exposes each discovered document and its generated Rust client.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn filesystem_discovery_document_and_client() {
use sausage_vfs::{Layer, Open};
let w = TestWorld::new();
let document =
json!({"methods":[{"name":"echo","params":[],"result":{"schema":{"type":"string"}}}]});
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
.open("/proc/core/plugins/probe/discover.json", Open::default())
.await
.is_ok()
{
break;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
let published = call(
&h,
"probe.read",
json!({"path":"/proc/core/plugins/probe/discover.json"}),
)
.await;
assert_eq!(
serde_json::from_str::<Value>(published.as_str().unwrap()).unwrap(),
document
);
let client = call(
&h,
"probe.read",
json!({"path":"/proc/core/plugins/probe/client.rs"}),
)
.await;
assert_eq!(
client,
sausage_interface::client_rs("probe", &document).unwrap()
);
h.stop().await;
}
/// PROTOCOL 4: run is shared across processes but empty after a host restart.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn filesystem_run_is_shared_and_ephemeral() {
use sausage_vfs::Layer;
let w = TestWorld::new();
let h = w.start().await;
call(
&h,
"probe.write",
json!({"path":"/run/shared","text":"yes"}),
)
.await;
assert_eq!(
call(&h, "probe.read", json!({"path":"/run/shared"})).await,
"yes"
);
h.stop().await;
let h = w.start().await;
assert!(
h.filesystem()
.await
.unwrap()
.list("/run")
.await
.unwrap()
.is_empty()
);
h.stop().await;
}
/// PROTOCOL 6: no write from a killed process lands after the next holder acquires its lock.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn filesystem_kill_drains_writes_before_lock_transfer() {
let w = TestWorld::new();
let h = w.start().await;
let pid = call(&h, "probe.write_loop", json!({})).await;
let next = h.clone();
let waiter = tokio::spawn(async move { call(&next, "probe.locked_write", json!({})).await });
use sausage_vfs::{Layer, Open};
let view = h.filesystem().await.unwrap();
tokio::time::timeout(std::time::Duration::from_secs(20), async {
loop {
let file = view
.open("/proc/core/locks.json", Open::default())
.await
.unwrap();
let locks: Value = serde_json::from_slice(&file.read(0, 65536).await.unwrap()).unwrap();
if locks["/state/loop"]["waiting"]
.as_array()
.is_some_and(|queue| !queue.is_empty())
{
break;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
call(&h, "core.kill", json!({"pid":pid})).await;
waiter.await.unwrap();
assert_eq!(
std::fs::read_to_string(w.0.join("state/loop")).unwrap(),
"new"
);
h.stop().await;
}
/// PROTOCOL 4: native processes appear in proc and locks.json identifies holders and waiters.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn filesystem_native_processes_and_lock_snapshot() {
use sausage_vfs::{Layer, Open};
let w = TestWorld::new();
let h = w.start().await;
h.register_native(
"native",
std::sync::Arc::new(|ctx, _, _| {
Box::pin(async move {
ctx.call("core.lock", json!({"path":"live"})).await.unwrap();
ctx.notify("ready", json!({"pid":ctx.pid}));
std::future::pending().await
})
}),
);
let mut events = h.subscribe();
let host = h.clone();
let task = tokio::spawn(async move { host.call("native.wait", json!({})).await });
let event = events.recv().await.unwrap();
let pid = event["params"]["params"]["pid"].as_str().unwrap();
let view = h.filesystem().await.unwrap();
assert!(
view.list("/proc")
.await
.unwrap()
.iter()
.any(|e| e.name == pid)
);
let info = call(
&h,
"probe.read",
json!({"path":format!("/proc/{pid}/info.json")}),
)
.await;
assert_eq!(
serde_json::from_str::<Value>(info.as_str().unwrap()).unwrap()["version"],
"native"
);
let host = h.clone();
let waiter = tokio::spawn(async move { host.call("core.lock", json!({"path":"live"})).await });
tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
let file = view
.open("/proc/core/locks.json", Open::default())
.await
.unwrap();
let locks: Value = serde_json::from_slice(&file.read(0, 65536).await.unwrap()).unwrap();
if locks["live"]["waiting"] == json!(["console"]) {
assert_eq!(locks["live"]["owner"], pid);
break;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
call(&h, "core.kill", json!({"pid":pid})).await;
assert_eq!(waiter.await.unwrap().unwrap(), json!({"held":true}));
assert!(task.await.unwrap().is_err());
assert!(view.stat(&format!("/proc/{pid}"), true).await.is_err());
h.stop().await;
}