Use the world SDK for cargo builds and seed it only when missing

This commit is contained in:
Sausage Worker
2026-10-28 08:39:36 +00:00
parent dc81f78c20
commit 9ef73e6bc7
4 changed files with 138 additions and 54 deletions
+7 -2
View File
@@ -516,8 +516,13 @@ builds `/plugins/notes/source/`:
its caller ends (under the normal process stopping deadline). its caller ends (under the normal process stopping deadline).
- A call to `cargo.build` gets a process ID, a folder in `/proc`, and journal - A call to `cargo.build` gets a process ID, a folder in `/proc`, and journal
entries, like other calls. entries, like other calls.
- `/plugins/cargo/sdk/` shows the source of the SDK. The dependency - `/plugins/cargo/sdk/` is the SDK used by builds. The dependency
`sausage-plugin = "0.1"` points to the same files in the host's cache. `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 - Compiled crates stay in the host's cache, outside the world, and all worlds
share them. So a second build compiles only what changed. share them. So a second build compiles only what changed.
- Build scripts can compile C: `CC_wasm32_wasip2` and `AR_wasm32_wasip2` - Build scripts can compile C: `CC_wasm32_wasip2` and `AR_wasm32_wasip2`
+6
View File
@@ -97,6 +97,12 @@ config/boot.yaml the services that start with the world
config/agent.yaml the configuration of the agent 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), 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). `/self` (its own folder in `/proc`), and `/run` (shared files in memory).
+33 -43
View File
@@ -3,7 +3,6 @@ use crate::{Host, world::World};
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use include_dir::{Dir, include_dir}; use include_dir::{Dir, include_dir};
use serde_json::{Value, json}; use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::time::Duration; use std::time::Duration;
use std::{ use std::{
path::{Path, PathBuf}, path::{Path, PathBuf},
@@ -169,10 +168,9 @@ pub struct Cargo {
runner: PathBuf, runner: PathBuf,
target: PathBuf, target: PathBuf,
sdk: PathBuf, sdk: PathBuf,
sdk_files: Arc<std::collections::BTreeMap<PathBuf, Vec<u8>>>,
} }
impl Cargo { 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<Self> { pub fn new(world: World, cache: &Path) -> Result<Self> {
let mut runner = std::env::current_exe()?; let mut runner = std::env::current_exe()?;
if runner if runner
@@ -183,9 +181,23 @@ impl Cargo {
} }
Self::with_runner(world, runner, cache) 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<Self> { pub fn with_runner(world: World, runner: PathBuf, cache: &Path) -> Result<Self> {
let sdk = world.plugin_dir(ID).join("sdk"); 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(); let mut files = std::collections::BTreeMap::new();
collect_files(&SDK, Path::new("src"), &mut files); collect_files(&SDK, Path::new("src"), &mut files);
collect_files(&MACROS, Path::new("macros/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()); 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 { for (path, bytes) in &files {
let name = path.to_str().unwrap().as_bytes(); write_changed(&staging.path().join(path), 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)?;
} }
let cached = std::path::absolute(cache)? // Another builder may have seeded the world while we prepared the files.
.join("sdk") if !sdk.try_exists()?
.join(format!("{:x}", hash.finalize())); && let Err(error) = std::fs::rename(staging.path(), sdk)
Ok(Self { && !sdk.try_exists()?
sdk: cached, {
sdk_files: Arc::new(files), return Err(error).with_context(|| format!("seed {}", sdk.display()));
target: std::path::absolute(cache)?.join("cargo-target"), }
world, Ok(())
runner,
builds: Arc::new(tokio::sync::Mutex::new(())),
})
} }
/// Registers cargo.build as a cancellable native process. /// Registers cargo.build as a cancellable native process.
pub fn register(self, host: &Host) { pub fn register(self, host: &Host) {
@@ -310,27 +319,8 @@ impl Cargo {
let source = source.to_path_buf(); let source = source.to_path_buf();
let copy = work.clone(); let copy = work.clone();
let sdk = self.sdk.clone(); let sdk = self.sdk.clone();
let sdk_files = self.sdk_files.clone();
let build_guard = tokio::task::spawn_blocking(move || -> Result<_> { let build_guard = tokio::task::spawn_blocking(move || -> Result<_> {
// Host startup installs only the readable world copy. Builds populate the cache. Self::seed_sdk(&sdk)?;
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()));
}
}
if cleanup.exists() { if cleanup.exists() {
std::fs::remove_dir_all(&cleanup) std::fs::remove_dir_all(&cleanup)
.with_context(|| format!("remove {}", cleanup.display()))?; .with_context(|| format!("remove {}", cleanup.display()))?;
@@ -659,7 +649,7 @@ fn write_changed(path: &Path, bytes: &[u8]) -> Result<()> {
} }
Ok(()) Ok(())
} }
/// Collects embedded source files for deterministic hashing and installation. /// Collects embedded source files for seeding a missing world SDK.
fn collect_files( fn collect_files(
directory: &Dir<'_>, directory: &Dir<'_>,
destination: &Path, destination: &Path,
+92 -9
View File
@@ -278,14 +278,7 @@ async fn manifest_and_nested_sources() {
assert!(manifest.contains("optional = true")); assert!(manifest.contains("optional = true"));
let document: toml_edit::DocumentMut = manifest.parse().unwrap(); 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()); 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")); assert_eq!(sdk, valid.world.0.join("plugins/cargo/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!( assert_eq!(
std::fs::read_to_string(root.join("src/target/data")).unwrap(), std::fs::read_to_string(root.join("src/target/data")).unwrap(),
"nested target" "nested target"
@@ -483,7 +476,7 @@ async fn source_copy() {
assert!( assert!(
std::fs::read_to_string(valid.world.0.join("state/cargo/work/Cargo.toml")) std::fs::read_to_string(valid.world.0.join("state/cargo/work/Cargo.toml"))
.unwrap() .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. /// PROTOCOL 11: compiler failures return compiler diagnostics.
@@ -637,6 +630,96 @@ async fn unchanged_sdk() {
modified 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. /// PROTOCOL 11: init can build a plugin without a running host.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn standalone_builder() { async fn standalone_builder() {