builder: enforce target, source sync, and timeout contracts
This commit is contained in:
@@ -8,18 +8,27 @@ For `test` and `run`, arguments after `--` belong to the executable, not the hos
|
||||
Other commands retain their `--` arguments, such as `fmt -- --check` and Clippy lint options.
|
||||
|
||||
The host finds the Cargo workspace above the requested directory and copies its sources through the shared VFS.
|
||||
It excludes `target` and `.git`. Each workspace owns a source copy, target directory, registry, and kache cache.
|
||||
It excludes `target` and `.git`.
|
||||
|
||||
Each workspace owns a source copy, target directory, registry, and kache cache.
|
||||
These directories live under `<dir>/host/builds/`.
|
||||
|
||||
Cargo fetches dependencies with network access. The command then runs offline inside bubblewrap namespaces.
|
||||
Only the source copy, target, and private kache cache permit writes.
|
||||
The registry and Nix store remain read-only. HOME and the host network are absent.
|
||||
The PID namespace removes compiler descendants and the private kache daemon after Cargo exits.
|
||||
|
||||
The builder copies changed and new source files back through the VFS.
|
||||
It preserves other source files, including concurrent edits in the world.
|
||||
It publishes WASI executables into the world's workspace target directory.
|
||||
After `cargo clean`, it removes the published artifacts that Cargo removed from the native target.
|
||||
Every target build links the shared cwd-init object, including its numeric exit-code interface.
|
||||
The command deadline defaults to 30 minutes. A closed response cancels the native command and its process group.
|
||||
Every target build uses `wasm32-wasip2` and links the shared cwd-init object, including its numeric exit-code interface.
|
||||
The host rejects other build targets and `--target-dir` overrides.
|
||||
|
||||
The command deadline defaults to 30 minutes.
|
||||
The host kills timed-out commands before it sends their timeout message.
|
||||
A closed response cancels the native command and its process group.
|
||||
|
||||
The host retains at most 20 GiB of build directories and evicts the oldest workspace first.
|
||||
|
||||
The Nix wrapper supplies these tools on PATH:
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! Workspace discovery and source/artifact copies through the world's shared VFS.
|
||||
use anyhow::{Context, Result};
|
||||
use sausage_vfs::{Layer, Open};
|
||||
use std::path::Path;
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
/// Source hashes before the native command changes its copy.
|
||||
pub type Snapshot = HashMap<String, [u8; 32]>;
|
||||
|
||||
pub async fn read(root: &dyn Layer, path: &str) -> Result<Vec<u8>> {
|
||||
let file = root
|
||||
@@ -37,8 +40,9 @@ pub async fn workspace(root: &dyn Layer, cwd: &str) -> Result<String> {
|
||||
}
|
||||
nearest.context("no Cargo.toml above cwd")
|
||||
}
|
||||
pub async fn copy_out(root: &dyn Layer, source: &str, destination: &Path) -> Result<()> {
|
||||
pub async fn copy_out(root: &dyn Layer, source: &str, destination: &Path) -> Result<Snapshot> {
|
||||
std::fs::create_dir_all(destination)?;
|
||||
let mut before = Snapshot::new();
|
||||
for entry in root.list(source).await? {
|
||||
if matches!(entry.name.as_str(), "target" | ".git") {
|
||||
continue;
|
||||
@@ -46,12 +50,14 @@ pub async fn copy_out(root: &dyn Layer, source: &str, destination: &Path) -> Res
|
||||
let from = sausage_vfs::resolve(source, &entry.name);
|
||||
let to = destination.join(&entry.name);
|
||||
if entry.directory {
|
||||
Box::pin(copy_out(root, &from, &to)).await?;
|
||||
before.extend(Box::pin(copy_out(root, &from, &to)).await?);
|
||||
} else {
|
||||
std::fs::write(to, read(root, &from).await?)?;
|
||||
let bytes = read(root, &from).await?;
|
||||
before.insert(from, openssl::sha::sha256(&bytes));
|
||||
std::fs::write(to, bytes)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(before)
|
||||
}
|
||||
pub async fn mkdir(root: &dyn Layer, path: &str) -> Result<()> {
|
||||
let mut current = String::new();
|
||||
@@ -91,11 +97,12 @@ pub async fn write(root: &dyn Layer, path: &str, bytes: Vec<u8>) -> Result<()> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// A snapshot selects changed sources. Without a snapshot, publish only WASM artifacts.
|
||||
pub async fn copy_back(
|
||||
root: &dyn Layer,
|
||||
source: &Path,
|
||||
destination: &str,
|
||||
artifacts: bool,
|
||||
before: Option<&Snapshot>,
|
||||
) -> Result<()> {
|
||||
for entry in std::fs::read_dir(source)? {
|
||||
let entry = entry?;
|
||||
@@ -105,9 +112,12 @@ pub async fn copy_back(
|
||||
}
|
||||
let to = sausage_vfs::resolve(destination, &name);
|
||||
if entry.file_type()?.is_dir() {
|
||||
Box::pin(copy_back(root, &entry.path(), &to, artifacts)).await?;
|
||||
} else if !artifacts || entry.path().extension().is_some_and(|e| e == "wasm") {
|
||||
write(root, &to, std::fs::read(entry.path())?).await?;
|
||||
Box::pin(copy_back(root, &entry.path(), &to, before)).await?;
|
||||
} else if before.is_some() || entry.path().extension().is_some_and(|e| e == "wasm") {
|
||||
let bytes = std::fs::read(entry.path())?;
|
||||
if before.is_none_or(|before| before.get(&to) != Some(&openssl::sha::sha256(&bytes))) {
|
||||
write(root, &to, bytes).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
+34
-11
@@ -59,6 +59,25 @@ impl Builder {
|
||||
.contains(&action),
|
||||
"unsupported Cargo command"
|
||||
);
|
||||
let compiles = ["build", "check", "clippy", "test", "run"].contains(&action);
|
||||
let mut args = request.argv.iter().take_while(|arg| arg.as_str() != "--");
|
||||
while let Some(arg) = args.next() {
|
||||
ensure!(
|
||||
arg != "--target-dir" && !arg.starts_with("--target-dir="),
|
||||
"the builder controls the target directory"
|
||||
);
|
||||
let target = if arg == "--target" {
|
||||
args.next().map(String::as_str)
|
||||
} else {
|
||||
arg.strip_prefix("--target=")
|
||||
};
|
||||
if compiles && let Some(target) = target {
|
||||
ensure!(
|
||||
target == "wasm32-wasip2",
|
||||
"the build target is always wasm32-wasip2"
|
||||
);
|
||||
}
|
||||
}
|
||||
let seconds = request.timeout_secs.unwrap_or(1800);
|
||||
ensure!(seconds > 0, "timeout must be positive");
|
||||
let deadline = Instant::now()
|
||||
@@ -81,7 +100,7 @@ impl Builder {
|
||||
if source.exists() {
|
||||
std::fs::remove_dir_all(&source)?;
|
||||
}
|
||||
files::copy_out(self.root.as_ref(), &workspace, &source).await?;
|
||||
let before = files::copy_out(self.root.as_ref(), &workspace, &source).await?;
|
||||
for path in [&target, ®istry, &cache, &target.join("tmp")] {
|
||||
std::fs::create_dir_all(path)?;
|
||||
}
|
||||
@@ -147,7 +166,7 @@ impl Builder {
|
||||
startup.arg(tools.join("build.nu"));
|
||||
let (startup_code, _) = run(startup, tx, deadline).await?;
|
||||
if startup_code != 0 {
|
||||
files::copy_back(self.root.as_ref(), &source, &workspace, false).await?;
|
||||
files::copy_back(self.root.as_ref(), &source, &workspace, Some(&before)).await?;
|
||||
self.evict(20 * 1024 * 1024 * 1024)?;
|
||||
tx.send(Event::Exit {
|
||||
code: startup_code,
|
||||
@@ -238,7 +257,7 @@ impl Builder {
|
||||
}
|
||||
let split = args.iter().position(|a| a == "--").unwrap_or(args.len());
|
||||
cmd.args(&args[..split]);
|
||||
if ["build", "check", "clippy", "test", "run"].contains(&action) {
|
||||
if compiles {
|
||||
cmd.args([
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
@@ -257,7 +276,7 @@ impl Builder {
|
||||
self.root.as_ref(),
|
||||
&artifacts,
|
||||
&format!("{world_target}/wasm32-wasip2"),
|
||||
true,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -284,7 +303,7 @@ impl Builder {
|
||||
executables.dedup();
|
||||
}
|
||||
}
|
||||
files::copy_back(self.root.as_ref(), &source, &workspace, false).await?;
|
||||
files::copy_back(self.root.as_ref(), &source, &workspace, Some(&before)).await?;
|
||||
self.evict(20 * 1024 * 1024 * 1024)?;
|
||||
tx.send(Event::Exit { code, executables }).await?;
|
||||
Ok(())
|
||||
@@ -410,14 +429,18 @@ async fn run(
|
||||
)?;
|
||||
Ok::<_, anyhow::Error>((status.code().unwrap_or(1), out))
|
||||
};
|
||||
let result = tokio::select! {
|
||||
result = collect => result,
|
||||
let (result, timed_out) = tokio::select! {
|
||||
result = collect => (result, false),
|
||||
_ = tx.closed() => anyhow::bail!("caller exited"),
|
||||
_ = tokio::time::sleep_until(deadline) => {
|
||||
tx.send(Event::Stderr { bytes: b"cargo: command timed out\n".to_vec() }).await?;
|
||||
Ok((124, Vec::new()))
|
||||
}
|
||||
_ = tokio::time::sleep_until(deadline) => (Ok((124, Vec::new())), true),
|
||||
};
|
||||
// Kill the command before a full output channel can delay its timeout message.
|
||||
drop(group);
|
||||
if timed_out {
|
||||
tx.send(Event::Stderr {
|
||||
bytes: b"cargo: command timed out\n".to_vec(),
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
@@ -33,6 +33,41 @@ async fn discovers_workspace_and_skips_target() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn copies_only_changed_and_new_sources() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let world = crate::world::World::open(temp.path())?;
|
||||
for name in ["unchanged.rs", "changed.rs"] {
|
||||
files::write(
|
||||
world.root.as_ref(),
|
||||
&format!("/project/{name}"),
|
||||
b"initial".to_vec(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
let copy = temp.path().join("copy");
|
||||
let before = files::copy_out(world.root.as_ref(), "/project", ©).await?;
|
||||
files::write(
|
||||
world.root.as_ref(),
|
||||
"/project/unchanged.rs",
|
||||
b"concurrent edit".to_vec(),
|
||||
)
|
||||
.await?;
|
||||
std::fs::write(copy.join("changed.rs"), b"changed by command")?;
|
||||
std::fs::write(copy.join("Cargo.lock"), b"created by command")?;
|
||||
files::copy_back(world.root.as_ref(), ©, "/project", Some(&before)).await?;
|
||||
for (name, expected) in [
|
||||
("unchanged.rs", "concurrent edit"),
|
||||
("changed.rs", "changed by command"),
|
||||
("Cargo.lock", "created by command"),
|
||||
] {
|
||||
assert_eq!(
|
||||
files::read(world.root.as_ref(), &format!("/project/{name}")).await?,
|
||||
expected.as_bytes()
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn clean_removes_only_deleted_artifacts() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let world = crate::world::World::open(temp.path())?;
|
||||
@@ -69,6 +104,58 @@ fn relative_host_directory_uses_absolute_namespace_paths() -> Result<()> {
|
||||
assert!(builder.directory.is_absolute());
|
||||
Ok(())
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn target_options_preserve_the_platform_target_and_cache() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let world = crate::world::World::open(temp.path())?;
|
||||
let builder = Builder::new(world.root, &world.host_dir)?;
|
||||
let (tx, _rx) = mpsc::channel(1);
|
||||
for options in [
|
||||
vec!["--target", "aarch64-unknown-linux-gnu"],
|
||||
vec!["--target=aarch64-unknown-linux-gnu"],
|
||||
vec!["--target-dir", "/other"],
|
||||
vec!["--target-dir=/other"],
|
||||
] {
|
||||
let error = builder
|
||||
.execute(
|
||||
Request {
|
||||
argv: std::iter::once("build")
|
||||
.chain(options)
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
cwd: "/".into(),
|
||||
timeout_secs: None,
|
||||
},
|
||||
&tx,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("target"), "{error}");
|
||||
}
|
||||
for (action, options) in [
|
||||
("run", vec!["--target", "wasm32-wasip2"]),
|
||||
("run", vec!["--target=wasm32-wasip2"]),
|
||||
("run", vec!["--", "--target-dir", "/argument"]),
|
||||
("tree", vec!["--target", "all"]),
|
||||
] {
|
||||
let error = builder
|
||||
.execute(
|
||||
Request {
|
||||
argv: std::iter::once(action)
|
||||
.chain(options)
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
cwd: "/".into(),
|
||||
timeout_secs: None,
|
||||
},
|
||||
&tx,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.to_string(), "no Cargo.toml above cwd");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[test]
|
||||
fn test_filter_is_not_a_build_argument() {
|
||||
let args = ["test", "-p", "example", "filter", "--release"].map(str::to_string);
|
||||
@@ -96,6 +183,36 @@ fn disk_cap_removes_workspaces() -> Result<()> {
|
||||
assert_eq!(files::size(&builder.directory)?, 32);
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
#[tokio::test]
|
||||
async fn timeout_kills_before_output_can_drain() -> Result<()> {
|
||||
use std::os::fd::{FromRawFd, OwnedFd};
|
||||
let (tx, mut rx) = mpsc::channel(1);
|
||||
let output = tx.clone();
|
||||
let mut cmd = Command::new("sh");
|
||||
cmd.args(["-c", "echo $$; exec sleep 1000"]);
|
||||
let task =
|
||||
tokio::spawn(
|
||||
async move { run(cmd, &output, Instant::now() + Duration::from_secs(1)).await },
|
||||
);
|
||||
let Some(Event::Stdout { bytes }) =
|
||||
tokio::time::timeout(Duration::from_secs(2), rx.recv()).await?
|
||||
else {
|
||||
anyhow::bail!("missing native PID");
|
||||
};
|
||||
let pid: u32 = std::str::from_utf8(&bytes)?.trim().parse()?;
|
||||
let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) };
|
||||
ensure!(fd >= 0, "pidfd_open: {}", std::io::Error::last_os_error());
|
||||
let exited = tokio::io::unix::AsyncFd::new(unsafe { OwnedFd::from_raw_fd(fd as i32) })?;
|
||||
// Fill the output channel, so the timeout message cannot enter it.
|
||||
tx.send(Event::Stdout { bytes: Vec::new() }).await?;
|
||||
let _ready = tokio::time::timeout(Duration::from_secs(2), exited.readable()).await??;
|
||||
assert!(matches!(rx.recv().await, Some(Event::Stdout { .. })));
|
||||
assert!(matches!(rx.recv().await, Some(Event::Stderr { bytes })
|
||||
if bytes.as_slice() == b"cargo: command timed out\n"));
|
||||
assert_eq!(task.await??.0, 124);
|
||||
Ok(())
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn closed_receiver_cancels_command() -> Result<()> {
|
||||
let (tx, mut rx) = mpsc::channel(1);
|
||||
|
||||
Reference in New Issue
Block a user