278 lines
9.1 KiB
Rust
278 lines
9.1 KiB
Rust
//! P4 exit: the real console agent builds Rust and Node projects, then inspects undo history.
|
|
use anyhow::{Context, ensure};
|
|
use sausage::world::World;
|
|
use sausage_vfs::journal::Entry;
|
|
use std::{
|
|
fs,
|
|
io::Write,
|
|
path::{Path, PathBuf},
|
|
process::{Child, Command, Stdio},
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
fn checked(command: &mut Command) -> anyhow::Result<()> {
|
|
ensure!(command.status()?.success(), "command failed: {command:?}");
|
|
Ok(())
|
|
}
|
|
fn prepare(dir: &Path) -> anyhow::Result<PathBuf> {
|
|
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
|
|
let userland = source.join("userland");
|
|
let target = PathBuf::from(std::env::var("CARGO_TARGET_DIR")?);
|
|
let bundle = Path::new(env!("CARGO_TARGET_TMPDIR")).join("p4-bundle");
|
|
let revision = Command::new("git")
|
|
.current_dir(&source)
|
|
.args(["rev-parse", "HEAD"])
|
|
.output()?;
|
|
ensure!(revision.status.success(), "fixture revision");
|
|
if fs::read(bundle.join("revision")).ok().as_deref() != Some(revision.stdout.as_slice()) {
|
|
if !userland.join("cwd-init/init.o").exists() {
|
|
checked(Command::new("nu").arg(userland.join("cwd-init/build.nu")))?;
|
|
}
|
|
checked(Command::new("cargo").current_dir(&userland).args([
|
|
"build",
|
|
"--release",
|
|
"--locked",
|
|
"-p",
|
|
"git",
|
|
"-p",
|
|
"world-cargo",
|
|
"-p",
|
|
"box-cli",
|
|
"-p",
|
|
"undo",
|
|
"-p",
|
|
"sausage-coreutils",
|
|
]))?;
|
|
checked(Command::new("nu").arg(userland.join("brush/build.nu")))?;
|
|
fs::create_dir_all(bundle.join("bin"))?;
|
|
fs::create_dir_all(bundle.join("src"))?;
|
|
for (binary, name) in [
|
|
("brush", "sh"),
|
|
("brush", "bash"),
|
|
("git", "git"),
|
|
("cargo", "cargo"),
|
|
("box", "box"),
|
|
("undo", "undo"),
|
|
("cat", "cat"),
|
|
("mkdir", "mkdir"),
|
|
] {
|
|
fs::copy(
|
|
target.join(format!("wasm32-wasip2/release/{binary}.wasm")),
|
|
bundle.join(format!("bin/{name}.wasm")),
|
|
)?;
|
|
}
|
|
fs::write(bundle.join("revision"), revision.stdout)?;
|
|
}
|
|
World::open_with_userland(dir, Some(&bundle))?;
|
|
let repo = dir.join("world/src/sausage");
|
|
fs::create_dir_all(repo.join("extensions/p4"))?;
|
|
fs::write(
|
|
repo.join("extensions/p4/server.ts"),
|
|
include_str!("../js/p4-test-bundle.js"),
|
|
)?;
|
|
for args in [
|
|
vec!["init", "-b", "main"],
|
|
vec!["add", "."],
|
|
vec!["commit", "--no-gpg-sign", "-m", "P4 fixture"],
|
|
] {
|
|
checked(
|
|
Command::new("git")
|
|
.current_dir(&repo)
|
|
.args(args)
|
|
.env("GIT_AUTHOR_NAME", "P4 test")
|
|
.env("GIT_AUTHOR_EMAIL", "p4@example.com")
|
|
.env("GIT_COMMITTER_NAME", "P4 test")
|
|
.env("GIT_COMMITTER_EMAIL", "p4@example.com"),
|
|
)?;
|
|
}
|
|
Ok(bundle)
|
|
}
|
|
struct Running(Child);
|
|
impl Drop for Running {
|
|
fn drop(&mut self) {
|
|
let _ = self.0.kill();
|
|
let _ = self.0.wait();
|
|
}
|
|
}
|
|
fn round(dir: &Path, bundle: &Path, number: usize) -> anyhow::Result<()> {
|
|
let stdout = dir.join(format!("round-{number}.stdout"));
|
|
let stderr = dir.join(format!("round-{number}.stderr"));
|
|
let mut child = Running(
|
|
Command::new(env!("CARGO_BIN_EXE_sausage"))
|
|
.arg("run")
|
|
.arg(dir)
|
|
.arg("--userland")
|
|
.arg(bundle)
|
|
.args(["--api-listen", "127.0.0.1:0"])
|
|
.env("PI_PROXY_BASE_URL", "https://proxy.invalid")
|
|
.env("PI_PROXY_API_KEY", "faux-token")
|
|
.env_remove("CREDENTIALS_DIRECTORY")
|
|
.stdin(Stdio::piped())
|
|
.stdout(fs::File::create(&stdout)?)
|
|
.stderr(fs::File::create(&stderr)?)
|
|
.spawn()?,
|
|
);
|
|
writeln!(
|
|
child.0.stdin.take().context("console stdin")?,
|
|
"P4 start {number}"
|
|
)?;
|
|
let deadline = Instant::now() + Duration::from_secs(600);
|
|
loop {
|
|
if fs::read_to_string(&stdout)?.contains(&format!("P4_COMPLETE_{number}")) {
|
|
break;
|
|
}
|
|
if let Some(status) = child.0.try_wait()? {
|
|
anyhow::bail!("console exited: {status}");
|
|
}
|
|
ensure!(
|
|
Instant::now() < deadline,
|
|
"P4 round {number} exceeded its deadline"
|
|
);
|
|
std::thread::yield_now();
|
|
}
|
|
checked(Command::new("kill").args(["-TERM", &child.0.id().to_string()]))?;
|
|
let deadline = Instant::now() + Duration::from_secs(20);
|
|
loop {
|
|
if let Some(status) = child.0.try_wait()? {
|
|
ensure!(status.success(), "console shutdown: {status}");
|
|
break;
|
|
}
|
|
ensure!(
|
|
Instant::now() < deadline,
|
|
"console shutdown exceeded its deadline"
|
|
);
|
|
std::thread::yield_now();
|
|
}
|
|
Ok(())
|
|
}
|
|
fn boxes_removed(dir: &Path, bundle: &Path) -> anyhow::Result<()> {
|
|
let path = dir.join("world/tmp/p4-box-id");
|
|
if !path.exists() {
|
|
return Ok(());
|
|
}
|
|
let id = fs::read_to_string(path)?;
|
|
let id = id.trim();
|
|
ensure!(
|
|
id.starts_with("tc-") && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'),
|
|
"invalid box ID"
|
|
);
|
|
// The agent's EXIT trap is primary cleanup. Recover its box even if the console fails.
|
|
let output = Command::new(env!("CARGO_BIN_EXE_sausage"))
|
|
.arg("sh")
|
|
.arg(dir)
|
|
.arg("--userland")
|
|
.arg(bundle)
|
|
.arg("-c")
|
|
.arg("box ls")
|
|
.env_remove("CREDENTIALS_DIRECTORY")
|
|
.output()?;
|
|
ensure!(
|
|
output.status.success(),
|
|
"box ls: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
if String::from_utf8_lossy(&output.stdout).contains(id) {
|
|
checked(
|
|
Command::new(env!("CARGO_BIN_EXE_sausage"))
|
|
.arg("sh")
|
|
.arg(dir)
|
|
.arg("--userland")
|
|
.arg(bundle)
|
|
.arg("-c")
|
|
.arg(format!("box rm {id}"))
|
|
.env_remove("CREDENTIALS_DIRECTORY"),
|
|
)?;
|
|
anyhow::bail!("Agent left {id} active. Test cleanup removed it");
|
|
}
|
|
println!("P4 box absent from service inventory: {id}");
|
|
Ok(())
|
|
}
|
|
fn cache_hits(dir: &Path) -> anyhow::Result<u64> {
|
|
let workspace = fs::read_dir(dir.join("host/builds"))?
|
|
.next()
|
|
.context("builder workspace")??
|
|
.path();
|
|
let output = Command::new("kache")
|
|
.arg("stats")
|
|
.env("KACHE_CACHE_DIR", workspace.join("kache"))
|
|
.output()?;
|
|
ensure!(
|
|
output.status.success(),
|
|
"kache: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
let text = String::from_utf8(output.stdout)?;
|
|
println!("P4 PRIVATE CACHE:\n{text}");
|
|
let words: Vec<_> = text.split_whitespace().collect();
|
|
words
|
|
.windows(2)
|
|
.find_map(|pair| {
|
|
if pair[1] == "of" {
|
|
pair[0].replace(',', "").parse().ok()
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.context("cache hit count")
|
|
}
|
|
#[test]
|
|
fn agent_builds_rust_and_node_twice() -> anyhow::Result<()> {
|
|
let dir = tempfile::tempdir()?;
|
|
let bundle = prepare(dir.path())?;
|
|
let mut first_hits = 0;
|
|
for number in 1..=2 {
|
|
let outcome = round(dir.path(), &bundle, number);
|
|
let cleanup = boxes_removed(dir.path(), &bundle);
|
|
if outcome.is_err() {
|
|
for stream in ["stdout", "stderr"] {
|
|
eprintln!(
|
|
"P4 round {number} {stream}:\n{}",
|
|
fs::read_to_string(dir.path().join(format!("round-{number}.{stream}")))?
|
|
);
|
|
}
|
|
}
|
|
outcome?;
|
|
cleanup?;
|
|
let world = dir.path().join("world");
|
|
ensure!(
|
|
world
|
|
.join("src/demo-cli/target/wasm32-wasip2/release/demo-cli.wasm")
|
|
.is_file(),
|
|
"missing world artifact"
|
|
);
|
|
ensure!(
|
|
fs::read_to_string(world.join("src/demo-cli/Cargo.toml"))?.contains("serde_json"),
|
|
"cargo add did not reach world"
|
|
);
|
|
ensure!(
|
|
fs::read_to_string(world.join("src/demo-node/result.txt"))? == "P4 npm test passed\n",
|
|
"Node result missing"
|
|
);
|
|
let entries: Vec<Entry> =
|
|
serde_json::from_slice(&fs::read(world.join("tmp/p4-undo.json"))?)?;
|
|
for (phase, path) in [
|
|
(0, "/src/demo-cli/Cargo.toml"),
|
|
(1, "/src/demo-cli/src/main.rs"),
|
|
] {
|
|
let tag = format!("p4-{number}-{phase}");
|
|
ensure!(
|
|
entries.iter().any(|entry| entry.tag == tag
|
|
&& entry.program == "bash"
|
|
&& entry.changed_paths.iter().any(|p| p == path)),
|
|
"undo lost {tag}: {path}"
|
|
);
|
|
}
|
|
let hits = cache_hits(dir.path())?;
|
|
if number == 1 {
|
|
first_hits = hits;
|
|
} else {
|
|
ensure!(
|
|
hits > first_hits,
|
|
"second run missed kache: {first_hits} -> {hits}"
|
|
);
|
|
println!("P4 CACHE SECOND RUN: {first_hits} -> {hits} hits");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|