diff --git a/PROTOCOL.md b/PROTOCOL.md index ac47812..a0f1154 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -516,8 +516,13 @@ builds `/plugins/notes/source/`: its caller ends (under the normal process stopping deadline). - A call to `cargo.build` gets a process ID, a folder in `/proc`, and journal entries, like other calls. -- `/plugins/cargo/sdk/` shows the source of the SDK. The dependency - `sausage-plugin = "0.1"` points to the same files in the host's cache. +- `/plugins/cargo/sdk/` is the SDK used by builds. The dependency + `sausage-plugin = "0.1"` points directly to this folder, including its macros + and interface crate. Edits apply to the next build without a host restart. +- The host seeds a missing SDK folder from its embedded copy at startup or + before a build. An existing folder is never overwritten or repaired, even + after a host upgrade. To reset to the host's bundled SDK, remove the whole + folder; the next startup or build re-seeds it. Back up local edits first. - Compiled crates stay in the host's cache, outside the world, and all worlds share them. So a second build compiles only what changed. - Build scripts can compile C: `CC_wasm32_wasip2` and `AR_wasm32_wasip2` diff --git a/README.md b/README.md index 6a672f5..f623eaa 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,12 @@ config/boot.yaml the services that start with the world config/agent.yaml the configuration of the agent ``` +Rust builds use the SDK in `plugins/cargo/sdk/` directly. The host seeds this +folder only when it is missing; restarts and host upgrades preserve existing +files. SDK edits take effect on the next `cargo.build`, without a restart. +To reset to the host's bundled SDK, back up any edits and remove the whole +folder; the next startup or build re-seeds it. + Each process also sees `/proc` (the processes and the state of the core), `/self` (its own folder in `/proc`), and `/run` (shared files in memory). diff --git a/cli/src/cargo.rs b/cli/src/cargo.rs index 717f6c6..fe935d8 100644 --- a/cli/src/cargo.rs +++ b/cli/src/cargo.rs @@ -3,7 +3,6 @@ use crate::{Host, world::World}; use anyhow::{Context, Result, bail}; use include_dir::{Dir, include_dir}; use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; use std::time::Duration; use std::{ path::{Path, PathBuf}, @@ -169,10 +168,9 @@ pub struct Cargo { runner: PathBuf, target: PathBuf, sdk: PathBuf, - sdk_files: Arc>>, } impl Cargo { - /// Installs changed SDK files and selects the current sausage executable as runner. + /// Seeds a missing world SDK and selects the current sausage executable as runner. pub fn new(world: World, cache: &Path) -> Result { let mut runner = std::env::current_exe()?; if runner @@ -183,9 +181,23 @@ impl Cargo { } Self::with_runner(world, runner, cache) } - /// Installs the SDK and accepts an explicit sausage binary, including for init integrations. + /// Seeds a missing world SDK and accepts an explicit sausage binary, including for init integrations. pub fn with_runner(world: World, runner: PathBuf, cache: &Path) -> Result { let sdk = world.plugin_dir(ID).join("sdk"); + Self::seed_sdk(&sdk)?; + Ok(Self { + sdk, + target: std::path::absolute(cache)?.join("cargo-target"), + world, + runner, + builds: Arc::new(tokio::sync::Mutex::new(())), + }) + } + /// Seeds only an absent SDK; an existing world owns all of its SDK files. + fn seed_sdk(sdk: &Path) -> Result<()> { + if sdk.try_exists()? { + return Ok(()); + } let mut files = std::collections::BTreeMap::new(); collect_files(&SDK, Path::new("src"), &mut files); collect_files(&MACROS, Path::new("macros/src"), &mut files); @@ -223,26 +235,23 @@ impl Cargo { } files.insert(destination, document.to_string().into_bytes()); } - let mut hash = Sha256::new(); + let parent = sdk.parent().context("SDK has no parent")?; + std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + let staging = tempfile::Builder::new() + .prefix("seed-sdk-") + .tempdir_in(parent) + .with_context(|| format!("stage SDK in {}", parent.display()))?; for (path, bytes) in &files { - let name = path.to_str().unwrap().as_bytes(); - hash.update((name.len() as u64).to_le_bytes()); - hash.update(name); - hash.update((bytes.len() as u64).to_le_bytes()); - hash.update(bytes); - write_changed(&sdk.join(path), bytes)?; + write_changed(&staging.path().join(path), bytes)?; } - let cached = std::path::absolute(cache)? - .join("sdk") - .join(format!("{:x}", hash.finalize())); - Ok(Self { - sdk: cached, - sdk_files: Arc::new(files), - target: std::path::absolute(cache)?.join("cargo-target"), - world, - runner, - builds: Arc::new(tokio::sync::Mutex::new(())), - }) + // Another builder may have seeded the world while we prepared the files. + if !sdk.try_exists()? + && let Err(error) = std::fs::rename(staging.path(), sdk) + && !sdk.try_exists()? + { + return Err(error).with_context(|| format!("seed {}", sdk.display())); + } + Ok(()) } /// Registers cargo.build as a cancellable native process. pub fn register(self, host: &Host) { @@ -310,27 +319,8 @@ impl Cargo { let source = source.to_path_buf(); let copy = work.clone(); let sdk = self.sdk.clone(); - let sdk_files = self.sdk_files.clone(); let build_guard = tokio::task::spawn_blocking(move || -> Result<_> { - // Host startup installs only the readable world copy. Builds populate the cache. - if !sdk.is_dir() { - let parent = sdk.parent().unwrap(); - std::fs::create_dir_all(parent) - .with_context(|| format!("create {}", parent.display()))?; - let staging = tempfile::Builder::new() - .prefix("install-") - .tempdir_in(parent) - .with_context(|| format!("stage SDK in {}", parent.display()))?; - for (path, bytes) in sdk_files.iter() { - write_changed(&staging.path().join(path), bytes)?; - } - // Another builder can publish the same complete SDK first. - if let Err(error) = std::fs::rename(staging.path(), &sdk) - && !sdk.is_dir() - { - return Err(error).with_context(|| format!("install {}", sdk.display())); - } - } + Self::seed_sdk(&sdk)?; if cleanup.exists() { std::fs::remove_dir_all(&cleanup) .with_context(|| format!("remove {}", cleanup.display()))?; @@ -659,7 +649,7 @@ fn write_changed(path: &Path, bytes: &[u8]) -> Result<()> { } Ok(()) } -/// Collects embedded source files for deterministic hashing and installation. +/// Collects embedded source files for seeding a missing world SDK. fn collect_files( directory: &Dir<'_>, destination: &Path, diff --git a/cli/tests/host/cargo.rs b/cli/tests/host/cargo.rs index e860322..120b262 100644 --- a/cli/tests/host/cargo.rs +++ b/cli/tests/host/cargo.rs @@ -278,14 +278,7 @@ async fn manifest_and_nested_sources() { assert!(manifest.contains("optional = true")); let document: toml_edit::DocumentMut = manifest.parse().unwrap(); let sdk = Path::new(document["target"]["cfg(target_arch = \"wasm32\")"]["dependencies"]["sausage_plugin"]["path"].as_str().unwrap()); - assert_eq!(sdk.parent().unwrap(), Path::new(CACHE).join("sdk")); - let hash = sdk.file_name().unwrap().to_str().unwrap(); - assert_eq!(hash.len(), 64); - assert!(hash.bytes().all(|byte| byte.is_ascii_hexdigit())); - assert_eq!( - std::fs::read(sdk.join("src/lib.rs")).unwrap(), - std::fs::read(valid.world.0.join("plugins/cargo/sdk/src/lib.rs")).unwrap() - ); + assert_eq!(sdk, valid.world.0.join("plugins/cargo/sdk")); assert_eq!( std::fs::read_to_string(root.join("src/target/data")).unwrap(), "nested target" @@ -483,7 +476,7 @@ async fn source_copy() { assert!( std::fs::read_to_string(valid.world.0.join("state/cargo/work/Cargo.toml")) .unwrap() - .contains(Path::new(CACHE).join("sdk").to_str().unwrap()) + .contains(valid.world.0.join("plugins/cargo/sdk").to_str().unwrap()) ); } /// PROTOCOL 11: compiler failures return compiler diagnostics. @@ -637,6 +630,96 @@ async fn unchanged_sdk() { modified ); } +/// PROTOCOL 11: an SDK edit is used by the next build on the same running host. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn world_sdk_edits_apply_without_restart() { + tokio::time::timeout(std::time::Duration::from_secs(180), async { + let world = TestWorld::new(); + world.simple("sdk-probe", "fn main() {}\n#[test]\nfn world_sdk_probe() { assert_eq!(sausage_plugin::world_sdk_probe(), 1); }\n"); + let manifest = world.0.join("plugins/sdk-probe/source/Cargo.toml"); + let mut text = std::fs::read_to_string(&manifest).unwrap(); + text.push_str("\n[dependencies]\nsausage-plugin = '0.1'\n"); + std::fs::write(manifest, text).unwrap(); + let host = Host::start_with_cache(&world.0, Path::new(CACHE)).await.unwrap(); + let library = world.0.join("plugins/cargo/sdk/src/lib.rs"); + let original = std::fs::read_to_string(&library).unwrap(); + std::fs::write(&library, format!("{original}\npub fn world_sdk_probe() -> u8 {{ 1 }}\n")).unwrap(); + let params = json!({"plugin":"sdk-probe", "timeout_secs":120, "test_timeout_secs":10}); + let first = host.call("cargo.build", params.clone()).await.unwrap(); + assert_eq!(first["tests"]["passed"], 1); + // Only the SDK changes: Cargo must not reuse its old compiled version. + std::fs::write(&library, format!("{original}\npub fn world_sdk_probe() -> u8 {{ 2 }}\n")).unwrap(); + let second = host.call("cargo.build", params).await.unwrap_err(); + assert_eq!(second["code"], -32004); + assert_eq!(second["data"]["tests"]["failed"], 1); + assert!(second["data"]["tests"]["output"].as_str().unwrap().contains("world_sdk_probe")); + host.stop().await; + }).await.expect("SDK edit regression timed out"); +} +/// PROTOCOL 11: startup seeds a missing SDK, but never repairs or overwrites an existing one. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn world_sdk_survives_restart_and_missing_sdk_is_reseeded() { + tokio::time::timeout(std::time::Duration::from_secs(30), async { + let world = TestWorld::new(); + let sdk = world.0.join("plugins/cargo/sdk"); + let host = Host::start_with_cache(&world.0, Path::new(CACHE)) + .await + .unwrap(); + let library = sdk.join("src/lib.rs"); + let original = std::fs::read(&library).unwrap(); + let manifest = sdk.join("Cargo.toml"); + let edited = b"// world SDK edit must survive restart\n"; + std::fs::write(&library, edited).unwrap(); + std::fs::write(&manifest, "# edited manifest\n").unwrap(); + let removed = sdk.join("macros/src/lib.rs"); + std::fs::remove_file(&removed).unwrap(); + host.stop().await; + let host = Host::start_with_cache(&world.0, Path::new(CACHE)) + .await + .unwrap(); + assert_eq!(std::fs::read(&library).unwrap(), edited); + assert_eq!( + std::fs::read_to_string(&manifest).unwrap(), + "# edited manifest\n" + ); + assert!(!removed.exists(), "existing SDK must not be repaired"); + host.stop().await; + std::fs::remove_dir_all(&sdk).unwrap(); + let host = Host::start_with_cache(&world.0, Path::new(CACHE)) + .await + .unwrap(); + assert_eq!(std::fs::read(&library).unwrap(), original); + assert!(removed.is_file()); + assert!(sdk.join("interface/src/lib.rs").is_file()); + host.stop().await; + }) + .await + .expect("SDK restart regression timed out"); +} +/// PROTOCOL 11: removing the SDK while the host is running re-seeds it on the next build. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn world_sdk_missing_at_build_is_reseeded() { + tokio::time::timeout(std::time::Duration::from_secs(180), async { + let world = TestWorld::new(); + world.simple("reseed-probe", "fn main() {}\n"); + let host = Host::start_with_cache(&world.0, Path::new(CACHE)) + .await + .unwrap(); + let sdk = world.0.join("plugins/cargo/sdk"); + let original = std::fs::read(sdk.join("src/lib.rs")).unwrap(); + std::fs::remove_dir_all(&sdk).unwrap(); + host.call( + "cargo.build", + json!({"plugin":"reseed-probe", "timeout_secs":120, "test_timeout_secs":10}), + ) + .await + .unwrap(); + assert_eq!(std::fs::read(sdk.join("src/lib.rs")).unwrap(), original); + host.stop().await; + }) + .await + .expect("SDK re-seed build regression timed out"); +} /// PROTOCOL 11: init can build a plugin without a running host. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn standalone_builder() {