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:
+180
-49
@@ -1,55 +1,176 @@
|
||||
//! Component versions cached by file metadata, with independent compilation per hash.
|
||||
use crate::{error, runtime::Runtime};
|
||||
//! Metadata-keyed component loads and weak compiled versions; candidate copies never enter the path cache.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// PROTOCOL 10.3: a pending component load does not occupy admission.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn loading_does_not_hold_admission() {
|
||||
let folder =
|
||||
std::env::temp_dir().join(format!("sausage-admission-{}", uuid::Uuid::now_v7()));
|
||||
let world = crate::world::World::create(&folder).unwrap();
|
||||
let path = world.plugin_dir("probe").join("plugin.wasm");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, []).unwrap();
|
||||
let host = crate::Host::start_with_cache(&folder, &folder.join("cache"))
|
||||
.await
|
||||
.unwrap();
|
||||
let entry = host
|
||||
.0
|
||||
.components
|
||||
.paths
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(path)
|
||||
.or_default()
|
||||
.clone();
|
||||
let blocked = entry.lock().await;
|
||||
let caller = host.clone();
|
||||
let pending =
|
||||
tokio::spawn(async move { caller.call("probe.echo", serde_json::json!({})).await });
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
while Arc::strong_count(&entry) < 3 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let admitted = host.0.admission.try_write().is_ok();
|
||||
drop(blocked);
|
||||
assert!(pending.await.unwrap().is_err());
|
||||
host.stop().await;
|
||||
std::fs::remove_dir_all(folder).unwrap();
|
||||
assert!(admitted, "a component load held the admission gate");
|
||||
}
|
||||
/// PROTOCOL 10.3: temporary discovery and activation copies never accumulate cached paths.
|
||||
#[tokio::test]
|
||||
async fn candidate_copies_do_not_enter_path_cache() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let target = root.join("target/core/guests");
|
||||
assert!(
|
||||
std::process::Command::new("cargo")
|
||||
.args([
|
||||
"build",
|
||||
"--offline",
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
"--manifest-path"
|
||||
])
|
||||
.arg(root.join("cli/tests/plugins/probe/Cargo.toml"))
|
||||
.arg("--target-dir")
|
||||
.arg(&target)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
let folder =
|
||||
std::env::temp_dir().join(format!("sausage-component-cache-{}", uuid::Uuid::now_v7()));
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
let runtime = Runtime::new(&root.join("target/core/cache-test")).unwrap();
|
||||
let cache = Components::default();
|
||||
for name in ["discover-test.wasm", "activate-test.wasm"] {
|
||||
let path = folder.join(name);
|
||||
std::fs::copy(target.join("wasm32-wasip2/debug/probe.wasm"), &path).unwrap();
|
||||
let loaded = cache.load(path.clone(), runtime.clone()).await.unwrap();
|
||||
assert!(cache.paths.lock().unwrap().is_empty());
|
||||
let weak = Arc::downgrade(&loaded.prepared);
|
||||
drop(loaded);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
assert!(weak.upgrade().is_none());
|
||||
}
|
||||
std::fs::remove_dir(folder).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
use crate::{
|
||||
error,
|
||||
runtime::{Prepared, Runtime},
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex, Weak},
|
||||
time::SystemTime,
|
||||
};
|
||||
use tokio::sync::{Mutex as AsyncMutex, OnceCell};
|
||||
use wasmtime::component::Component;
|
||||
|
||||
struct Version {
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub(crate) struct Fingerprint {
|
||||
size: u64,
|
||||
modified: SystemTime,
|
||||
hash: String,
|
||||
component: Component,
|
||||
#[cfg(unix)]
|
||||
identity: (u64, u64, i64, i64),
|
||||
}
|
||||
type PathEntry = Arc<AsyncMutex<Option<Version>>>;
|
||||
type CompiledEntry = Arc<OnceCell<Component>>;
|
||||
|
||||
impl Fingerprint {
|
||||
pub(crate) async fn read(path: &Path) -> Result<Self, serde_json::Value> {
|
||||
let metadata = tokio::fs::metadata(path)
|
||||
.await
|
||||
.map_err(|_| error(-32001, "no such plugin"))?;
|
||||
Ok(Self {
|
||||
size: metadata.len(),
|
||||
modified: metadata
|
||||
.modified()
|
||||
.map_err(|e| error(-32603, &e.to_string()))?,
|
||||
#[cfg(unix)]
|
||||
identity: {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
(
|
||||
metadata.dev(),
|
||||
metadata.ino(),
|
||||
metadata.ctime(),
|
||||
metadata.ctime_nsec(),
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Loaded {
|
||||
pub(crate) prepared: Arc<Prepared>,
|
||||
pub(crate) hash: String,
|
||||
pub(crate) fingerprint: Fingerprint,
|
||||
// Retain the per-hash cell only while a cached path refers to this version.
|
||||
_compiled: Arc<OnceCell<Arc<Prepared>>>,
|
||||
}
|
||||
type PathEntry = Arc<AsyncMutex<Option<Loaded>>>;
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Components {
|
||||
paths: Mutex<HashMap<PathBuf, PathEntry>>,
|
||||
compiled: Mutex<HashMap<String, CompiledEntry>>,
|
||||
compiled: Mutex<HashMap<String, Weak<OnceCell<Arc<Prepared>>>>>,
|
||||
}
|
||||
impl Components {
|
||||
/// Stat each path, but read and hash its bytes only after its metadata changes.
|
||||
pub(crate) async fn load(
|
||||
&self,
|
||||
path: PathBuf,
|
||||
runtime: Arc<Runtime>,
|
||||
) -> Result<(Component, String), serde_json::Value> {
|
||||
let entry = self
|
||||
.paths
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(path.clone())
|
||||
.or_default()
|
||||
.clone();
|
||||
) -> Result<Loaded, serde_json::Value> {
|
||||
let candidate = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.starts_with("activate-") || n.starts_with("discover-"));
|
||||
let entry = if candidate {
|
||||
Arc::default()
|
||||
} else {
|
||||
self.paths
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(path.clone())
|
||||
.or_default()
|
||||
.clone()
|
||||
};
|
||||
let mut cached = entry.lock().await;
|
||||
let metadata = tokio::fs::metadata(&path)
|
||||
.await
|
||||
.map_err(|_| error(-32001, "no such plugin"))?;
|
||||
let modified = metadata
|
||||
.modified()
|
||||
.map_err(|e| error(-32603, &e.to_string()))?;
|
||||
let fingerprint = match Fingerprint::read(&path).await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) => {
|
||||
*cached = None;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
if let Some(version) = cached.as_ref()
|
||||
&& version.size == metadata.len()
|
||||
&& version.modified == modified
|
||||
&& version.fingerprint == fingerprint
|
||||
{
|
||||
return Ok((version.component.clone(), version.hash.clone()));
|
||||
return Ok(version.clone());
|
||||
}
|
||||
let (bytes, hash) = tokio::task::spawn_blocking(move || -> std::io::Result<_> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
@@ -59,28 +180,38 @@ impl Components {
|
||||
.await
|
||||
.map_err(|e| error(-32603, &e.to_string()))?
|
||||
.map_err(|e| error(-32603, &e.to_string()))?;
|
||||
let compiled = self
|
||||
.compiled
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(hash.clone())
|
||||
.or_default()
|
||||
.clone();
|
||||
let component = compiled
|
||||
let compiled = {
|
||||
let mut versions = self.compiled.lock().unwrap();
|
||||
versions.retain(|_, v| v.strong_count() > 0);
|
||||
if let Some(cell) = versions.get(&hash).and_then(Weak::upgrade) {
|
||||
cell
|
||||
} else {
|
||||
let cell = Arc::new(OnceCell::new());
|
||||
versions.insert(hash.clone(), Arc::downgrade(&cell));
|
||||
cell
|
||||
}
|
||||
};
|
||||
let prepared = compiled
|
||||
.get_or_try_init(|| async move {
|
||||
tokio::task::spawn_blocking(move || runtime.compile(&bytes))
|
||||
.await
|
||||
.map_err(|e| error(-32603, &e.to_string()))?
|
||||
.map_err(|e| error(-32603, &e.to_string()))
|
||||
tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
|
||||
let component = runtime.compile(&bytes)?;
|
||||
Ok(Arc::new(runtime.prepare(&component)))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| error(-32603, &e.to_string()))?
|
||||
.map_err(|e| error(-32603, &e.to_string()))
|
||||
})
|
||||
.await?
|
||||
.clone();
|
||||
*cached = Some(Version {
|
||||
size: metadata.len(),
|
||||
modified,
|
||||
hash: hash.clone(),
|
||||
component: component.clone(),
|
||||
});
|
||||
Ok((component, hash))
|
||||
let loaded = Loaded {
|
||||
prepared,
|
||||
hash,
|
||||
fingerprint,
|
||||
_compiled: compiled,
|
||||
};
|
||||
if !candidate {
|
||||
*cached = Some(loaded.clone());
|
||||
}
|
||||
Ok(loaded)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user