//! Commit snapshots and last-good recovery with world git and a faux model. use sausage::{ exec::{Command, Exec, Options, Stream}, world::World, }; use std::{path::Path, sync::Arc}; const BUNDLE: &str = include_str!("../../js/extensions-test-bundle.js"); struct Author(Arc); #[async_trait::async_trait] impl Exec for Author { async fn exec( &self, command: Command, mut options: Options, output: &mut (dyn for<'a> FnMut(Stream, &'a [u8]) + Send), ) -> anyhow::Result { for (key, value) in [ ("GIT_AUTHOR_NAME", "Test"), ("GIT_AUTHOR_EMAIL", "test@example.com"), ("GIT_COMMITTER_NAME", "Test"), ("GIT_COMMITTER_EMAIL", "test@example.com"), ] { options.env.push((key.into(), value.into())); } let label = format!("{command:?}"); let budget = options.timeout; let started = std::time::Instant::now(); let result = self.0.exec(command, options, output).await; eprintln!( "Extension {label}: deadline {budget:?}, elapsed {:?}, result {result:?}", started.elapsed() ); result } } #[tokio::test(flavor = "current_thread")] async fn commits_and_restart() -> anyhow::Result<()> { let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland"); let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap(); if !userland.join("cwd-init/init.o").is_file() { crate::common::cwd_init(); } assert!( std::process::Command::new("cargo") .args([ "build", "--release", "-p", "git", "-p", "sausage-exec-tools", "-p", "sausage-archive-tools", "--manifest-path" ]) .arg(userland.join("Cargo.toml")) .args(["--target", "wasm32-wasip2", "--target-dir"]) .arg(target) .args(["-j", "2", "--locked"]) .current_dir(&userland) .status()? .success() ); let dir = tempfile::tempdir()?; let world = World::open(dir.path())?; let disk = dir.path().join("world"); std::fs::create_dir_all(disk.join("bin"))?; std::fs::copy( target.join("wasm32-wasip2/release/git.wasm"), disk.join("bin/git.wasm"), )?; for name in ["extensions-revert", "env", "tar"] { std::fs::copy( target.join(format!("wasm32-wasip2/release/{name}.wasm")), disk.join(format!("bin/{name}.wasm")), )?; } let repo = disk.join("src/sausage"); std::fs::create_dir_all(&repo)?; assert!( std::process::Command::new("git") .args(["init", "-b", "main"]) .arg(&repo) .status()? .success() ); assert!( std::process::Command::new("git") .args([ "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "--no-gpg-sign", "--allow-empty", "-m", "empty" ]) .current_dir(&repo) .status()? .success() ); let exec: Arc = Arc::new(Author(sausage::process::exec(World { root: world.root.clone(), host_dir: world.host_dir.clone(), processes: world.processes.clone(), })?)); sausage::harness::run_module(world, exec, BUNDLE).await?; let good = std::fs::read_to_string(repo.join("good.txt"))?; let world = World::open(dir.path())?; let exec: Arc = Arc::new(Author(sausage::process::exec(World { root: world.root.clone(), host_dir: world.host_dir.clone(), processes: world.processes.clone(), })?)); sausage::harness::run_module( world, exec, &format!("globalThis.extensionRestart=true;globalThis.extensionGood={good:?};\n{BUNDLE}"), ) .await?; Ok(()) } /// Model loader commands without delays, and require cancellation instead of a wall-clock deadline. struct LoaderCommand { root: Arc, failures: usize, attempts: std::sync::atomic::AtomicUsize, timeout: bool, } #[async_trait::async_trait] impl Exec for LoaderCommand { async fn exec( &self, command: Command, options: Options, output: &mut (dyn for<'a> FnMut(Stream, &'a [u8]) + Send), ) -> anyhow::Result { let Command::Argv(args) = command else { anyhow::bail!("unexpected shell") }; assert!( options.timeout.is_none(), "Loader work must not have a wall-clock deadline" ); if args[0] == "tar" { return Ok(0); } match args[1].as_str() { "rev-parse" => output(Stream::Stdout, b"new\n"), "ls-tree" => output(Stream::Stdout, b"extensions\n"), "archive" => { let attempt = self .attempts .fetch_add(1, std::sync::atomic::Ordering::SeqCst); if attempt < self.failures && self.timeout { anyhow::bail!("command timed out"); } if attempt < self.failures { anyhow::bail!("checkout failed"); } if attempt > 0 { let status = self .root .open("/proc/extensions", Default::default()) .await?; let status = String::from_utf8(status.read(0, 65536).await?)?; assert!( status.contains("command timed out"), "Retry must publish its error: {status}" ); } let archive = args[3].strip_prefix("--output=").unwrap(); let file = self .root .open( archive, sausage_vfs::Open { write: true, create: true, ..Default::default() }, ) .await?; file.write(0, vec![]).await?; let snapshot = format!("{}.tmp", archive.strip_suffix(".tar").unwrap()); for path in [ format!("{snapshot}/extensions"), format!("{snapshot}/extensions/demo"), ] { self.root.mkdir(&path).await?; } let file = self .root .open( &format!("{snapshot}/extensions/demo/server.ts"), sausage_vfs::Open { write: true, create: true, ..Default::default() }, ) .await?; file.write(0, b"export default {name:'demo'};".to_vec()) .await?; } _ => anyhow::bail!("unexpected command {args:?}"), } Ok(0) } } async fn loader_command(failures: usize, timeout: bool) -> anyhow::Result<()> { let dir = tempfile::tempdir()?; let world = World::open(dir.path())?; std::fs::create_dir_all(dir.path().join("world/src/sausage/.git"))?; let exec = Arc::new(LoaderCommand { root: world.root.clone(), failures, timeout, attempts: std::sync::atomic::AtomicUsize::new(0), }); let (library, _) = include_str!("../../js/bundle.js") .split_once("// main.ts") .unwrap(); let expected = if failures > 1 || !timeout && failures > 0 { "old" } else { "new" }; let source = format!( r#"{library} const testRegistry = createRegistry(); const testStorage = await openStorage(globalThis.storagePath); const testHarness = await Harness.open(testStorage, {{models:createModels(),registry:testRegistry}}, BACKGROUND_CONTEXT); await testHarness.root(BACKGROUND_CONTEXT); await testHarness.commit(async tx => Object.assign(await tx.doc(ExtensionState), {{good:'old',current:'old',error:null}}), BACKGROUND_CONTEXT); for (const path of ['/run/sausage','/run/sausage/extensions','/run/sausage/extensions/old','/run/sausage/extensions/old/extensions','/run/sausage/extensions/old/extensions/demo']) await fs2.mkdir(path); await fs2.write('/run/sausage/extensions/old/extensions/demo/server.ts', "export default {{name:'demo'}};"); const testLoader = await loadExtensions(testHarness,testRegistry,testStorage,createModels(),BACKGROUND_CONTEXT); const testState = testLoader.state(); if(testState.good !== '{expected}') throw new Error(JSON.stringify(testState)); if((testState.error !== null) !== ({expected:?} === 'old')) throw new Error(JSON.stringify(testState)); if(testState.error && !testState.error.includes('git archive')) throw new Error(testState.error); await testLoader.close(); await testHarness.close(BACKGROUND_CONTEXT); "# ); sausage::harness::run_module(world, exec.clone(), &source).await?; assert_eq!( exec.attempts.load(std::sync::atomic::Ordering::SeqCst), if timeout && failures > 0 { 2 } else { 1 } ); Ok(()) } #[tokio::test(flavor = "current_thread")] async fn slow_loader_command() -> anyhow::Result<()> { loader_command(0, true).await } #[tokio::test(flavor = "current_thread")] async fn loader_timeout_retries_once() -> anyhow::Result<()> { loader_command(1, true).await } #[tokio::test(flavor = "current_thread")] async fn loader_timeout_keeps_last_good() -> anyhow::Result<()> { loader_command(2, true).await } #[tokio::test(flavor = "current_thread")] async fn loader_real_failure_is_not_retried() -> anyhow::Result<()> { loader_command(1, false).await }