From eaf1b7cd1b74881e25247272f017afd2150ee8c8 Mon Sep 17 00:00:00 2001 From: Mark Dittmer Date: Sat, 23 May 2026 14:33:22 +0000 Subject: [PATCH] [anneal][v2] Add charon execution engine, expand command CLI, and integration tests gherrit-pr-id: Gnafivbpqcaatsvsxamgsjcudchscvaae --- anneal/v2/examples/simple.rs | 7 + anneal/v2/src/charon.rs | 646 ++++++++++++++++++++++++++++ anneal/v2/src/diagnostics.rs | 1 + anneal/v2/src/main.rs | 279 ++++++++---- anneal/v2/src/resolve.rs | 2 + anneal/v2/src/scanner.rs | 1 + anneal/v2/src/setup.rs | 1 + anneal/v2/src/util.rs | 1 + anneal/v2/tests/integration.rs | 128 ++++++ anneal/v2/tests/lock_integration.rs | 167 +++++++ 10 files changed, 1147 insertions(+), 86 deletions(-) create mode 100644 anneal/v2/examples/simple.rs create mode 100644 anneal/v2/src/charon.rs create mode 100644 anneal/v2/tests/integration.rs create mode 100644 anneal/v2/tests/lock_integration.rs diff --git a/anneal/v2/examples/simple.rs b/anneal/v2/examples/simple.rs new file mode 100644 index 0000000000..0b36b00521 --- /dev/null +++ b/anneal/v2/examples/simple.rs @@ -0,0 +1,7 @@ +pub fn add(left: usize, right: usize) -> usize { + left + right +} + +fn main() { + println!("Hello, world! {}", add(1, 2)); +} diff --git a/anneal/v2/src/charon.rs b/anneal/v2/src/charon.rs new file mode 100644 index 0000000000..5e44d35460 --- /dev/null +++ b/anneal/v2/src/charon.rs @@ -0,0 +1,646 @@ +// Copyright 2026 The Fuchsia Authors +// +// Licensed under the 2-Clause BSD License , Apache License, Version 2.0 +// , or the MIT +// license , at your option. +// This file may not be copied, modified, or distributed except according to +// those terms. + +//! Orchestration of Charon extraction. +//! +//! This module handles the invocation of the `charon` tool to extract +//! Low-Level Borrow Calculus (LLBC) from Rust crates. It manages: +//! - Setting up the Charon command and arguments (including features, +//! targets, and output paths). +//! - Handling `unsafe(axiom)` functions by marking them as opaque to Charon. +//! - Streaming and filtering compiler output to provide user-friendly +//! feedback via `indicatif` and `miette`. +//! - Validating the extraction result. + +use anyhow::Context as _; +use rayon::prelude::IntoParallelRefIterator as _; +use rayon::prelude::ParallelIterator as _; + +/// Runs Charon on the specified packages to generate LLBC artifacts. +/// +/// This function requires [`crate::resolve::LockedRoots`] to ensure that it has exclusive access +/// to the `llbc` output directory. It iterates over each [`crate::scanner::AnnealArtifact`], +/// constructs the appropriate `charon` command, and executes it. +/// +/// It handles: +/// - **Opaque Functions**: identifying `unsafe(axiom)` functions and passing +/// `--opaque` to Charon. +/// - **Entry Points**: passing the computed `start_from` roots to Charon to +/// minimize extraction scope. +/// - **Output Handling**: capturing stdout/stderr, parsing JSON compiler +/// messages, and rendering them using [`crate::diagnostics::DiagnosticMapper`]. +pub fn run_charon( + args: &crate::resolve::Args, + toolchain: &crate::setup::Toolchain, + roots: &crate::resolve::LockedRoots, + packages: &[crate::scanner::AnnealArtifact], + show_progress: bool, +) -> anyhow::Result<()> { + let llbc_root = roots.llbc_root(); + std::fs::create_dir_all(&llbc_root).context("Failed to create LLBC output directory")?; + + // Global print mutex to prevent interleaved printing of consolidated artifact buffers. + let print_mutex = std::sync::Arc::new(std::sync::Mutex::new(())); + + // Initialize MultiProgress if progress is enabled. + let mp = if show_progress { + Some(std::sync::Arc::new(indicatif::MultiProgress::new())) + } else { + None + }; + + packages.par_iter().try_for_each(|artifact| { + let pb = mp.as_ref().map(|m| { + let pb = m.add(indicatif::ProgressBar::new_spinner()); + pb.set_style( + indicatif::ProgressStyle::default_spinner() + .template("{spinner:.green} {msg}") + .unwrap(), + ); + pb.set_message("Compiling..."); + pb + }); + + log::info!("Invoking Charon on package '{}'...", artifact.name.package_name); + + let mut cmd = toolchain.command(crate::setup::Tool::Charon)?; + + cmd.arg("cargo"); + cmd.arg("--preset=aeneas"); + + let llbc_path = artifact.llbc_path(roots); + cmd.arg("--dest-file").arg(llbc_path); + + // Fail fast on errors: if Charon (or `rustc`) encounters a compilation error or + // translation failure (e.g., an unsupported Rust feature), it will terminate + // the process immediately rather than attempting to proceed and translate other parts of the crate. + cmd.arg("--abort-on-error"); + + // Separator for the underlying cargo command. + cmd.arg("--"); + + // Ensure cargo emits json msgs which charon-driver natively generates. + cmd.arg("--message-format=json"); + + cmd.arg("--manifest-path").arg(&artifact.manifest_path); + + use crate::resolve::AnnealTargetKind::*; + match artifact.target_kind { + Lib | RLib | ProcMacro | CDyLib | DyLib | StaticLib => cmd.arg("--lib"), + Bin => cmd.args(["--bin", &artifact.name.target_name]), + Example => cmd.args(["--example", &artifact.name.target_name]), + Test => cmd.args(["--test", &artifact.name.target_name]), + }; + + // Forward all feature-related flags. + if args.features.all_features { + cmd.arg("--all-features"); + } + if args.features.no_default_features { + cmd.arg("--no-default-features"); + } + for feature in &args.features.features { + cmd.arg("--features").arg(feature); + } + + // Reuse the main target directory for dependencies to save time: share + // `CARGO_TARGET_DIR` (`target/anneal/cargo_target`) across all Charon + // invocations to enable Cargo's incremental build cache. + cmd.env("CARGO_TARGET_DIR", roots.cargo_target_dir()); + + log::debug!("Executing charon command: {:?}", cmd); + + let start = std::time::Instant::now(); + let output_error = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let output_error_clone = std::sync::Arc::clone(&output_error); + + // Charon's standard error stream contains unstructured diagnostic + // output (such as panic messages from build scripts or ICEs). We + // collect this in a safety buffer to ensure that even if Charon aborts + // unexpectedly, the user receives the complete unstructured output + // instead of a generic "silent death". + let safety_buffer = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let safety_buffer_clone = std::sync::Arc::clone(&safety_buffer); + + // Local buffer to collect all output (diagnostics) for this artifact. + let artifact_diagnostics = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let artifact_diagnostics_clone = std::sync::Arc::clone(&artifact_diagnostics); + + let mut mapper = crate::diagnostics::DiagnosticMapper::new(roots.workspace().clone()); + + let pb_clone = pb.clone(); + let res = crate::util::run_command_with_progress(cmd, pb_clone, move |line, pb| { + if let Ok(msg) = serde_json::from_str::(line) { + match msg { + cargo_metadata::Message::CompilerArtifact(a) => { + if let Some(p) = pb { + p.set_message(format!("Compiling {}", a.target.name)); + } + } + cargo_metadata::Message::CompilerMessage(msg) => { + let mut rendered = String::new(); + mapper.render_miette(&msg.message, |s| rendered.push_str(&s)); + if !rendered.is_empty() { + artifact_diagnostics_clone.lock().unwrap().push(rendered); + } + if matches!( + msg.message.level, + cargo_metadata::diagnostic::DiagnosticLevel::Error + | cargo_metadata::diagnostic::DiagnosticLevel::Ice + ) { + output_error_clone.store(true, std::sync::atomic::Ordering::Relaxed); + } + } + cargo_metadata::Message::TextLine(t) => { + safety_buffer_clone.lock().unwrap().push(t); + } + _ => {} + } + } else { + safety_buffer_clone.lock().unwrap().push(line.to_string()); + } + Ok(()) + })?; + + log::trace!("Charon for '{}' took {:.2?}", artifact.name.package_name, start.elapsed()); + + // Lock the print mutex to print this artifact's consolidated output atomically. + let _lock = print_mutex.lock().unwrap(); + + // Print all collected diagnostics for this artifact. + let diags = artifact_diagnostics.lock().unwrap(); + if !diags.is_empty() { + eprintln!("=== Diagnostics for '{}' ===", artifact.name.package_name); + for diag in diags.iter() { + eprintln!("{}", diag); + } + } + + if output_error.load(std::sync::atomic::Ordering::Relaxed) { + anyhow::bail!("Diagnostic error in charon"); + } else if !res.status.success() { + // Print safety buffer on failure (also atomically since we hold print_mutex). + eprintln!("=== Failure output for '{}' ===", artifact.name.package_name); + for line in safety_buffer.lock().unwrap().iter() { + eprintln!("{}", line); + } + // Also dump the dynamic linker errors or panic messages captured in stderr. + for line in &res.stderr_lines { + eprintln!("{}", line); + } + anyhow::bail!("Charon failed with status: {}", res.status); + } + + Ok(()) + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + #[cfg(feature = "exocrate_tests")] + use super::*; + #[cfg(feature = "exocrate_tests")] + use crate::resolve::{Args, resolve_roots}; + #[cfg(feature = "exocrate_tests")] + use crate::scanner::scan_workspace; + #[cfg(feature = "exocrate_tests")] + use clap::Parser as _; + #[cfg(feature = "exocrate_tests")] + use std::fs; + + // Shared helper to parse LLBC output and verify local status and compiled body variant. + #[cfg(feature = "exocrate_tests")] + fn assert_fn_body( + path: &std::path::Path, + components: &[&str], + expected_local: bool, + expected_structured: bool, + ) { + let file = std::fs::File::open(path).unwrap(); + let crate_data: charon_lib::export::CrateData = serde_json::from_reader(file).unwrap(); + + let fun = crate_data + .translated + .fun_decls + .iter() + .find(|fun| { + let name = &fun.item_meta.name; + if name.name.len() != components.len() { + return false; + } + for (i, elem) in name.name.iter().enumerate() { + match elem { + charon_lib::ast::PathElem::Ident(ident, _) => { + if ident != components[i] { + return false; + } + } + _ => return false, + } + } + true + }) + .unwrap_or_else(|| { + panic!("Function with name path {:?} was not found/declared in LLBC!", components) + }); + + assert_eq!( + fun.item_meta.is_local, expected_local, + "Local/External status mismatch for function {:?}", + components + ); + + if expected_structured { + assert!( + matches!(fun.body, charon_lib::ullbc_ast::Body::Structured(_)), + "Function {:?} was expected to contain a compiled structured implementation body!", + components + ); + } else { + assert!( + matches!(fun.body, charon_lib::ullbc_ast::Body::Opaque), + "Function {:?} was expected to be Opaque (referred to but implementation body NOT included)!", + components + ); + } + } + + #[cfg(feature = "exocrate_tests")] + fn resolve_test_toolchain() -> &'static crate::setup::Toolchain { + static TOOLCHAIN: std::sync::OnceLock = std::sync::OnceLock::new(); + TOOLCHAIN.get_or_init(|| { + crate::setup::run_setup(crate::setup::SetupArgs { + local_archive: Some("target/anneal-exocrate.tar.zst".into()), + }) + .expect("Failed to run setup"); + crate::setup::Toolchain::resolve().expect("Failed to resolve toolchain") + }) + } + + #[cfg(feature = "exocrate_tests")] + #[test] + fn test_run_charon_simple() { + // 1. Create a temporary directory. + let temp_dir = tempfile::tempdir().unwrap(); + let proj_dir = temp_dir.path().join("test_proj"); + fs::create_dir_all(&proj_dir).unwrap(); + + // 2. Create a simple Cargo.toml. + let cargo_toml = r#" + [package] + name = "test_proj" + version = "0.1.0" + edition = "2021" + + [lib] + path = "src/lib.rs" + "#; + fs::write(proj_dir.join("Cargo.toml"), cargo_toml).unwrap(); + + // 3. Create a simple src/lib.rs. + fs::create_dir_all(proj_dir.join("src")).unwrap(); + let lib_rs = r#" + pub fn add(left: usize, right: usize) -> usize { + left + right + } + "#; + fs::write(proj_dir.join("src").join("lib.rs"), lib_rs).unwrap(); + + // 4. Construct Args pointing to this temp project. + let args = Args::try_parse_from(&[ + "cargo-anneal", + "--manifest-path", + proj_dir.join("Cargo.toml").to_str().unwrap(), + ]) + .unwrap(); + + // 5. Resolve roots. + let toolchain = resolve_test_toolchain(); + let roots = resolve_roots(&args, toolchain).unwrap(); + + // 6. Scan workspace. + let packages = scan_workspace(&roots).unwrap(); + assert_eq!(packages.len(), 1); + + // 7. Lock run root. + let locked_roots = roots.lock_run_root().unwrap(); + + // 8. Run Charon. + + let res = run_charon( + &args, + &toolchain, + &locked_roots, + &packages, + false, // show_progress + ); + assert!(res.is_ok(), "charon failed: {:?}", res.err()); + + // 9. Verify .llbc file exists. + let llbc_path = packages[0].llbc_path(&locked_roots); + assert!(llbc_path.exists(), "llbc file not found at {:?}", llbc_path); + } + + #[cfg(feature = "exocrate_tests")] + #[test] + fn test_run_charon_multiple_modules() { + let _ = env_logger::builder().is_test(true).try_init(); + + // 1. Create a temporary workspace with multiple modules. + let temp_dir = tempfile::tempdir().unwrap(); + crate::workspace_fixture!(&temp_dir, { + "Cargo.toml" => r#" + [package] + name = "test_proj" + version = "0.1.0" + edition = "2021" + + [lib] + path = "src/lib.rs" + "#, + "src/lib.rs" => r#" + pub mod foo; + pub mod bar; + "#, + "src/foo.rs" => r#" + pub fn foo_fn() {} + "#, + "src/bar.rs" => r#" + pub fn bar_fn() {} + "#, + }); + + // 2. Construct Args pointing to this temp project. + let args = Args::try_parse_from(&[ + "cargo-anneal", + "--manifest-path", + temp_dir.path().join("Cargo.toml").to_str().unwrap(), + ]) + .unwrap(); + + // 3. Resolve roots and scan workspace. + let toolchain = resolve_test_toolchain(); + let roots = resolve_roots(&args, toolchain).unwrap(); + let packages = scan_workspace(&roots).unwrap(); + assert_eq!(packages.len(), 1); + + // 4. Lock run root. + let locked_roots = roots.lock_run_root().unwrap(); + + // 5. Run Charon. + let res = run_charon( + &args, + &toolchain, + &locked_roots, + &packages, + false, // show_progress + ); + assert!(res.is_ok(), "charon failed: {:?}", res.err()); + + // 6. Verify .llbc file exists and contains BOTH functions. + let llbc_path = packages[0].llbc_path(&locked_roots); + assert!(llbc_path.exists(), "llbc file not found at {:?}", llbc_path); + + log::debug!("llbc path: {:?}", llbc_path); + + let llbc_content = std::fs::read_to_string(&llbc_path).expect("failed to read llbc file"); + + log::debug!("llbc content:\n'''\n{}\n'''", llbc_content); + + // Assert that the serialized AST contains the names of both functions + // defined in separate, independent modules. + assert!(llbc_content.contains("foo_fn"), "Function 'foo_fn' was not translated!"); + assert!(llbc_content.contains("bar_fn"), "Function 'bar_fn' was not translated!"); + } + + #[cfg(feature = "exocrate_tests")] + #[test] + fn test_charon_crates_io_dependency() { + let _ = env_logger::builder().is_test(true).try_init(); + + let temp_dir = tempfile::tempdir().unwrap(); + crate::workspace_fixture!(&temp_dir, { + "Cargo.toml" => r#" + [package] + name = "test_proj" + version = "0.1.0" + edition = "2021" + + [dependencies] + fs2 = "0.4.3" + + [lib] + path = "src/lib.rs" + "#, + "src/lib.rs" => r#" + pub fn get_free_space(path: &std::path::Path) -> u64 { + fs2::free_space(path).unwrap_or(0) + } + "#, + }); + + let args = Args::try_parse_from(&[ + "cargo-anneal", + "--manifest-path", + temp_dir.path().join("Cargo.toml").to_str().unwrap(), + ]) + .unwrap(); + + let toolchain = resolve_test_toolchain(); + let roots = resolve_roots(&args, toolchain).unwrap(); + let packages = scan_workspace(&roots).unwrap(); + let locked_roots = roots.lock_run_root().unwrap(); + + let res = run_charon(&args, &toolchain, &locked_roots, &packages, false); + assert!(res.is_ok(), "charon failed: {:?}", res.err()); + + let llbc_path = packages[0].llbc_path(&locked_roots); + assert!(llbc_path.exists()); + + // 1. Assert that the local function 'get_free_space' is fully translated with a structured body definition. + assert_fn_body(&llbc_path, &["test_proj", "get_free_space"], true, true); + + // 2. Assert that the crates.io dependency 'fs2::free_space' is referred to but NOT translated (opaque). + assert_fn_body(&llbc_path, &["fs2", "free_space"], false, false); + + // Standard library calls may be lowered directly into the calling crate rather than + // emitted as standalone LLBC declarations. + } + + #[cfg(feature = "exocrate_tests")] + #[test] + fn test_charon_path_dependency_behavior() { + let _ = env_logger::builder().is_test(true).try_init(); + + let temp_dir = tempfile::tempdir().unwrap(); + crate::workspace_fixture!(&temp_dir, { + "Cargo.toml" => r#" + [workspace] + resolver = "2" + members = [ + "test_proj", + ] + "#, + "my_dep/Cargo.toml" => r#" + [package] + name = "my_dep" + version = "0.1.0" + edition = "2021" + + [lib] + path = "src/lib.rs" + "#, + "my_dep/src/lib.rs" => r#" + pub fn dep_fn() {} + "#, + "test_proj/Cargo.toml" => r#" + [package] + name = "test_proj" + version = "0.1.0" + edition = "2021" + + [dependencies] + my_dep = { path = "../my_dep" } + + [lib] + path = "src/lib.rs" + "#, + "test_proj/src/lib.rs" => r#" + pub fn call_dep() { + my_dep::dep_fn(); + } + "#, + }); + + let args = Args::try_parse_from(&[ + "cargo-anneal", + "--manifest-path", + temp_dir.path().join("test_proj").join("Cargo.toml").to_str().unwrap(), + ]) + .unwrap(); + + let toolchain = resolve_test_toolchain(); + let roots = resolve_roots(&args, toolchain).unwrap(); + let packages = scan_workspace(&roots).unwrap(); + assert_eq!(packages.len(), 1); + let locked_roots = roots.lock_run_root().unwrap(); + + let res = run_charon(&args, &toolchain, &locked_roots, &packages, false); + assert!(res.is_ok(), "charon failed: {:?}", res.err()); + + let llbc_path = packages[0].llbc_path(&locked_roots); + assert!(llbc_path.exists()); + + // 1. Assert that local target function 'call_dep' is fully translated (definition included). + assert_fn_body(&llbc_path, &["test_proj", "call_dep"], true, true); + + // 2. Assert that local path dependency 'my_dep::dep_fn' is referred to but NOT translated (opaque). + assert_fn_body(&llbc_path, &["my_dep", "dep_fn"], false, false); + } + + #[cfg(feature = "exocrate_tests")] + #[test] + fn test_run_charon_multiple_packages() { + let _ = env_logger::builder().is_test(true).try_init(); + + // 1. Create a temporary workspace containing multiple packages. + let temp_dir = tempfile::tempdir().unwrap(); + crate::workspace_fixture!(&temp_dir, { + "Cargo.toml" => r#" + [workspace] + resolver = "2" + members = [ + "package_a", + "package_b", + ] + "#, + "package_a/Cargo.toml" => r#" + [package] + name = "package_a" + version = "0.1.0" + edition = "2021" + + [lib] + path = "src/lib.rs" + "#, + "package_a/src/lib.rs" => r#" + pub fn func_a() {} + "#, + "package_b/Cargo.toml" => r#" + [package] + name = "package_b" + version = "0.1.0" + edition = "2021" + + [lib] + path = "src/lib.rs" + "#, + "package_b/src/lib.rs" => r#" + pub fn func_b() {} + "#, + }); + + // 2. Construct Args pointing to this temp project. + let args = Args::try_parse_from(&[ + "cargo-anneal", + "--manifest-path", + temp_dir.path().join("Cargo.toml").to_str().unwrap(), + ]) + .unwrap(); + + // 3. Resolve roots and scan workspace. + let toolchain = resolve_test_toolchain(); + let roots = resolve_roots(&args, toolchain).unwrap(); + let packages = scan_workspace(&roots).unwrap(); + assert_eq!(packages.len(), 2, "Expected exactly two packages resolved in workspace"); + + // 4. Lock run root. + let locked_roots = roots.lock_run_root().unwrap(); + + // 5. Run Charon on both packages. + let res = run_charon( + &args, + &toolchain, + &locked_roots, + &packages, + false, // show_progress + ); + assert!(res.is_ok(), "charon failed: {:?}", res.err()); + + // 6. Verify .llbc file exists and contains correct code for Package A. + let llbc_path_a = packages[0].llbc_path(&locked_roots); + assert!(llbc_path_a.exists(), "llbc file for Package A not found at {:?}", llbc_path_a); + let llbc_content_a = + std::fs::read_to_string(&llbc_path_a).expect("failed to read llbc file A"); + assert!( + llbc_content_a.contains("func_a"), + "Function 'func_a' was not translated in Package A!" + ); + assert!( + !llbc_content_a.contains("func_b"), + "Function 'func_b' was incorrectly translated in Package A!" + ); + + // 7. Verify .llbc file exists and contains correct code for Package B. + let llbc_path_b = packages[1].llbc_path(&locked_roots); + assert!(llbc_path_b.exists(), "llbc file for Package B not found at {:?}", llbc_path_b); + let llbc_content_b = + std::fs::read_to_string(&llbc_path_b).expect("failed to read llbc file B"); + assert!( + llbc_content_b.contains("func_b"), + "Function 'func_b' was not translated in Package B!" + ); + assert!( + !llbc_content_b.contains("func_a"), + "Function 'func_a' was incorrectly translated in Package B!" + ); + } +} diff --git a/anneal/v2/src/diagnostics.rs b/anneal/v2/src/diagnostics.rs index 207ea567c9..70fdcb98de 100644 --- a/anneal/v2/src/diagnostics.rs +++ b/anneal/v2/src/diagnostics.rs @@ -242,6 +242,7 @@ impl DiagnosticMapper { /// bytes. /// 3. Anneal calls this method to print the error onto the Rust file /// canvas. + #[allow(dead_code)] pub fn render_raw( &mut self, file_name: &str, diff --git a/anneal/v2/src/main.rs b/anneal/v2/src/main.rs index 0cb74addc8..cfdf9d6640 100644 --- a/anneal/v2/src/main.rs +++ b/anneal/v2/src/main.rs @@ -7,8 +7,16 @@ // This file may not be copied, modified, or distributed except according to // those terms. +use anyhow::Context as _; use clap::Parser as _; +mod charon; +mod diagnostics; +mod resolve; +mod scanner; +mod setup; +mod util; + /// Anneal #[derive(clap::Parser, Debug)] #[command(name = "cargo-anneal", version, about, long_about = None)] @@ -21,54 +29,77 @@ struct Cli { enum Commands { /// Setup Anneal dependencies Setup(SetupArgs), + /// Expand a crate (runs Charon) + Expand(ExpandArgs), + + /// Helper to acquire shared or exclusive locks for multi-process integration testing (dev only) + #[cfg(feature = "exocrate_tests")] + TestLockHelper { + /// The role to run as: 'reader-a', 'reader-b', 'writer-a', or 'reader-exclusion' + #[arg(long)] + role: String, + /// Path to the directory to lock + #[arg(long)] + lock_dir: std::path::PathBuf, + /// Path to the shared log file where lock transitions are appended + #[arg(long)] + log_file: std::path::PathBuf, + /// Path to the temporary synchronization signal file + #[arg(long)] + sig_file: std::path::PathBuf, + }, } #[derive(clap::Parser, Debug)] pub struct SetupArgs { - /// Path to a local dependency archive to use instead of downloading. + /// Path to a local dependency archive to use instead of downloading #[arg(long, value_name = "path-to-local-archive")] pub local_archive: Option, } -exocrate::config! { - const CONFIG: Config = Config { - rel_dir_path: [".anneal", "toolchain"], - versioned_files: &["../Cargo.toml", "../Cargo.lock"], - }; -} +#[derive(clap::Parser, Debug)] +pub struct ExpandArgs { + #[command(flatten)] + pub resolve_args: crate::resolve::Args, -exocrate::parse_remote_archive! { - const REMOTE: RemoteArchive = "Cargo.toml" [ - (linux, x86_64), - (macos, x86_64), - (linux, aarch64), - (macos, aarch64), - ]; + /// Controls where LLBC output is placed on the filesystem + #[arg(long, value_name = "output-dir")] + pub output_dir: Option, + + /// Do not show compilation progress bars + #[arg(long)] + pub no_progress: bool, } -fn setup_installation_dir(args: SetupArgs) -> std::path::PathBuf { - let location = if std::env::var("__ANNEAL_LOCAL_DEV").is_ok() { - exocrate::Location::LocalDev - } else { - exocrate::Location::UserGlobal - }; - let source = match args.local_archive { - Some(local_archive) => exocrate::Source::Local(local_archive), - None => exocrate::Source::Remote(REMOTE), - }; - - CONFIG - .resolve_installation_dir_or_install(location, source) - // FIXME: Implement unified error reporting (e.g., via `anyhow`). - .expect("failed to resolve-or-install dependencies") +fn setup(args: SetupArgs) -> anyhow::Result<()> { + crate::setup::run_setup(crate::setup::SetupArgs { local_archive: args.local_archive }) + .context("Failed to setup toolchain") } -fn setup(args: SetupArgs) { - let installation_dir = setup_installation_dir(args); - log::info!("anneal toolchain is installed at {:?}", installation_dir); +fn expand(args: ExpandArgs) -> anyhow::Result<()> { + let toolchain = crate::setup::Toolchain::resolve()?; + let roots = crate::resolve::resolve_roots(&args.resolve_args, &toolchain)?; + let packages = crate::scanner::scan_workspace(&roots)?; + if packages.is_empty() { + log::warn!("No targets found to expand."); + return Ok(()); + } + let mut locked_roots = roots.lock_run_root()?; + if let Some(output_dir) = args.output_dir { + locked_roots.llbc_override = Some(output_dir); + } + let show_progress = !args.no_progress; + crate::charon::run_charon( + &args.resolve_args, + &toolchain, + &locked_roots, + &packages, + show_progress, + )?; + Ok(()) } -fn main() { +fn main() -> anyhow::Result<()> { // Suppressing timestamps removes a source of nondeterminism that is // difficult to work around in integration tests. env_logger::builder().format_timestamp(None).init(); @@ -83,6 +114,12 @@ fn main() { match args.command { Commands::Setup(args) => setup(args), + Commands::Expand(args) => expand(args), + + #[cfg(feature = "exocrate_tests")] + Commands::TestLockHelper { role, lock_dir, log_file, sig_file } => { + crate::util::run_test_lock_helper(&role, &lock_dir, &log_file, &sig_file) + } } } @@ -90,23 +127,73 @@ fn main() { mod tests { #[cfg(feature = "exocrate_tests")] mod exocrate_tests { - use std::{ - fs, io, - path::{Path, PathBuf}, - process::Command, - sync::OnceLock, - }; - - use serde_json::{Value, json}; - const LOCAL_ARCHIVE: &str = "target/anneal-exocrate.tar.zst"; - static INSTALLATION_DIR: OnceLock = OnceLock::new(); + static INSTALLATION_DIR: std::sync::OnceLock = + std::sync::OnceLock::new(); #[test] fn test_setup() { install_local_archive(); } + #[test] + fn test_setup_and_toolchain_paths() { + install_local_archive(); + + let toolchain = + crate::setup::Toolchain::resolve().expect("Failed to resolve toolchain"); + + assert!(toolchain.root().is_dir(), "root is not a directory: {:?}", toolchain.root()); + assert!( + toolchain.aeneas_bin_dir().is_dir(), + "aeneas_bin_dir is not a directory: {:?}", + toolchain.aeneas_bin_dir() + ); + assert!( + toolchain.rust_sysroot().is_dir(), + "rust_sysroot is not a directory: {:?}", + toolchain.rust_sysroot() + ); + assert!( + toolchain.rust_bin().is_dir(), + "rust_bin is not a directory: {:?}", + toolchain.rust_bin() + ); + assert!( + toolchain.rust_lib().is_dir(), + "rust_lib is not a directory: {:?}", + toolchain.rust_lib() + ); + assert!( + crate::setup::Tool::Cargo.path(&toolchain).is_file(), + "cargo is not a file: {:?}", + crate::setup::Tool::Cargo.path(&toolchain) + ); + assert!( + crate::setup::Tool::Rustc.path(&toolchain).is_file(), + "rustc is not a file: {:?}", + crate::setup::Tool::Rustc.path(&toolchain) + ); + + let cmd = toolchain + .command(crate::setup::Tool::Charon) + .expect("failed to construct charon command"); + assert_eq!( + command_env(&cmd, "CHARON_TOOLCHAIN_IS_IN_PATH"), + Some(std::ffi::OsStr::new("1")) + ); + assert_eq!( + std::env::split_paths(command_env(&cmd, "PATH").expect("PATH must be set")) + .next() + .as_deref(), + Some(toolchain.rust_bin().as_path()) + ); + let lib_var = + if cfg!(target_os = "macos") { "DYLD_LIBRARY_PATH" } else { "LD_LIBRARY_PATH" }; + assert_eq!(command_env(&cmd, lib_var), Some(toolchain.rust_lib().as_os_str())); + assert!(command_env(&cmd, "HOME").is_none()); + } + #[test] fn test_archive_lake_cache_reuse() { let installation_dir = install_local_archive(); @@ -118,21 +205,36 @@ mod tests { .expect("archive Lake cache reuse test failed"); } - fn install_local_archive() -> PathBuf { + fn install_local_archive() -> std::path::PathBuf { // ASSUMPTION: The CI dependency builder downloads the Nix-built // archive artifact to this path before running v2 tests. INSTALLATION_DIR .get_or_init(|| { - super::super::setup_installation_dir(super::super::SetupArgs { + super::super::setup(super::super::SetupArgs { local_archive: Some(LOCAL_ARCHIVE.into()), }) + .expect("Failed to run setup"); + crate::setup::Toolchain::resolve() + .expect("Failed to resolve toolchain") + .root() + .to_path_buf() }) .clone() } + fn command_env<'a>( + cmd: &'a std::process::Command, + name: &str, + ) -> Option<&'a std::ffi::OsStr> { + cmd.get_envs().find_map(|(key, value)| { + (key == std::ffi::OsStr::new(name)) + .then(|| value.expect("environment variable should be set")) + }) + } + fn assert_archive_lake_cache_reuse( - toolchain_root: &Path, - temp_root: &Path, + toolchain_root: &std::path::Path, + temp_root: &std::path::Path, ) -> Result<(), Box> { let aeneas_root = toolchain_root.join("aeneas"); let aeneas_lean = aeneas_root.join("backends/lean"); @@ -141,10 +243,10 @@ mod tests { assert_no_write_bits(&aeneas_root)?; - fs::create_dir_all(workspace.join("generated"))?; - fs::copy(aeneas_lean.join("lean-toolchain"), workspace.join("lean-toolchain"))?; - fs::write(workspace.join("generated/Generated.lean"), "import Aeneas\n")?; - fs::write( + std::fs::create_dir_all(workspace.join("generated"))?; + std::fs::copy(aeneas_lean.join("lean-toolchain"), workspace.join("lean-toolchain"))?; + std::fs::write(workspace.join("generated/Generated.lean"), "import Aeneas\n")?; + std::fs::write( workspace.join("lakefile.lean"), format!( r#"import Lake @@ -180,8 +282,8 @@ lean_lib Generated where Ok(()) } - fn assert_no_write_bits(root: &Path) -> Result<(), Box> { - let metadata = fs::symlink_metadata(root)?; + fn assert_no_write_bits(root: &std::path::Path) -> Result<(), Box> { + let metadata = std::fs::symlink_metadata(root)?; if metadata.file_type().is_symlink() { return Ok(()); } @@ -189,7 +291,7 @@ lean_lib Generated where panic!("archive path should be read-only: {}", root.display()); } if metadata.is_dir() { - for entry in fs::read_dir(root)? { + for entry in std::fs::read_dir(root)? { assert_no_write_bits(&entry?.path())?; } } @@ -197,26 +299,29 @@ lean_lib Generated where } #[cfg(unix)] - fn has_write_bits(permissions: &fs::Permissions) -> bool { + fn has_write_bits(permissions: &std::fs::Permissions) -> bool { use std::os::unix::fs::PermissionsExt as _; permissions.mode() & 0o222 != 0 } #[cfg(not(unix))] - fn has_write_bits(permissions: &fs::Permissions) -> bool { + fn has_write_bits(permissions: &std::fs::Permissions) -> bool { !permissions.readonly() } fn write_relative_archive_manifest( - workspace: &Path, - aeneas_lean: &Path, + workspace: &std::path::Path, + aeneas_lean: &std::path::Path, ) -> Result<(), Box> { - let aeneas_lean = fs::canonicalize(aeneas_lean)?; - let workspace = fs::canonicalize(workspace)?; + let aeneas_lean = std::fs::canonicalize(aeneas_lean)?; + let workspace = std::fs::canonicalize(workspace)?; let manifest_path = aeneas_lean.join("lake-manifest.json"); - let manifest: Value = serde_json::from_reader(fs::File::open(&manifest_path)?)?; - let aeneas_packages = - manifest.get("packages").and_then(Value::as_array).ok_or_else(|| { + let manifest: serde_json::Value = + serde_json::from_reader(std::fs::File::open(&manifest_path)?)?; + let aeneas_packages = manifest + .get("packages") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { invalid_data(format!( "Aeneas Lake manifest {} is missing packages", manifest_path.display() @@ -224,7 +329,7 @@ lean_lib Generated where })?; let aeneas_dir = relative_manifest_string(&aeneas_lean, &workspace)?; - let mut packages = vec![json!({ + let mut packages = vec![serde_json::json!({ "type": "path", "name": "aeneas", "dir": aeneas_dir, @@ -235,34 +340,36 @@ lean_lib Generated where let mut entry = entry.as_object().cloned().ok_or_else(|| { invalid_data("Aeneas Lake manifest package entry is not an object") })?; - let package_type = entry.get("type").and_then(Value::as_str).ok_or_else(|| { - invalid_data("Aeneas Lake manifest package entry is missing type") - })?; + let package_type = + entry.get("type").and_then(serde_json::Value::as_str).ok_or_else(|| { + invalid_data("Aeneas Lake manifest package entry is missing type") + })?; if package_type != "path" { return Err(invalid_data(format!( "Aeneas Lake manifest package entry is {package_type:?}, not a path dependency" )) .into()); } - let package_dir = entry.get("dir").and_then(Value::as_str).ok_or_else(|| { - invalid_data("Aeneas Lake manifest package entry is missing dir") - })?; - let package_dir = Path::new(package_dir); + let package_dir = + entry.get("dir").and_then(serde_json::Value::as_str).ok_or_else(|| { + invalid_data("Aeneas Lake manifest package entry is missing dir") + })?; + let package_dir = std::path::Path::new(package_dir); let package_dir = if package_dir.is_absolute() { package_dir.to_path_buf() } else { aeneas_lean.join(package_dir) }; - let package_dir = fs::canonicalize(package_dir)?; + let package_dir = std::fs::canonicalize(package_dir)?; entry.insert( "dir".to_string(), - json!(relative_manifest_string(&package_dir, &workspace)?), + serde_json::json!(relative_manifest_string(&package_dir, &workspace)?), ); - entry.insert("inherited".to_string(), json!(true)); - packages.push(Value::Object(entry)); + entry.insert("inherited".to_string(), serde_json::json!(true)); + packages.push(serde_json::Value::Object(entry)); } - let manifest = json!({ + let manifest = serde_json::json!({ "version": "1.2.0", "packagesDir": ".lake/packages", "packages": packages, @@ -270,7 +377,7 @@ lean_lib Generated where "lakeDir": ".lake", "fixedToolchain": false, }); - fs::write( + std::fs::write( workspace.join("lake-manifest.json"), format!("{}\n", serde_json::to_string_pretty(&manifest)?), )?; @@ -278,8 +385,8 @@ lean_lib Generated where } fn relative_manifest_string( - path: &Path, - base: &Path, + path: &std::path::Path, + base: &std::path::Path, ) -> Result> { let path = pathdiff::diff_paths(path, base).ok_or_else(|| { invalid_data(format!( @@ -291,17 +398,17 @@ lean_lib Generated where Ok(path.to_string_lossy().into_owned()) } - fn lake_string(path: &Path) -> String { + fn lake_string(path: &std::path::Path) -> String { path.to_string_lossy().replace('\\', "\\\\").replace('"', "\\\"") } fn run_lake_archive_command( - workspace: &Path, - lean_root: &Path, + workspace: &std::path::Path, + lean_root: &std::path::Path, args: &[&str], ) -> Result<(), Box> { let lean_bin = lean_root.join("bin"); - let mut cmd = Command::new(lean_bin.join("lake")); + let mut cmd = std::process::Command::new(lean_bin.join("lake")); cmd.args(args).current_dir(workspace).env_clear(); let lib_var = @@ -313,7 +420,7 @@ lean_lib Generated where let output = cmd.output()?; if !output.status.success() { - return Err(io::Error::other(format!( + return Err(std::io::Error::other(format!( "lake {:?} failed with status {}\nstdout:\n{}\nstderr:\n{}", args, output.status, @@ -325,8 +432,8 @@ lean_lib Generated where Ok(()) } - fn invalid_data(message: impl Into) -> io::Error { - io::Error::new(io::ErrorKind::InvalidData, message.into()) + fn invalid_data(message: impl Into) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::InvalidData, message.into()) } } } diff --git a/anneal/v2/src/resolve.rs b/anneal/v2/src/resolve.rs index e35c67f0f2..cca2c5b68a 100644 --- a/anneal/v2/src/resolve.rs +++ b/anneal/v2/src/resolve.rs @@ -213,10 +213,12 @@ impl<'a> LockedRoots<'a> { } } + #[allow(dead_code)] pub fn lean_root(&self) -> std::path::PathBuf { self.anneal_run_root.path.join("lean") } + #[allow(dead_code)] pub fn lean_generated_root(&self) -> std::path::PathBuf { self.lean_root().join("generated") } diff --git a/anneal/v2/src/scanner.rs b/anneal/v2/src/scanner.rs index 1209894e85..fb090583c1 100644 --- a/anneal/v2/src/scanner.rs +++ b/anneal/v2/src/scanner.rs @@ -91,6 +91,7 @@ impl AnnealArtifact { } /// Returns the name of the `.lean` spec file to use for this artifact. + #[allow(dead_code)] pub fn lean_spec_file_name(&self) -> String { format!("{}.lean", self.artifact_slug()) } diff --git a/anneal/v2/src/setup.rs b/anneal/v2/src/setup.rs index 650ceab494..43c2407ab9 100644 --- a/anneal/v2/src/setup.rs +++ b/anneal/v2/src/setup.rs @@ -71,6 +71,7 @@ impl Toolchain { Ok(Self { root }) } + #[allow(dead_code)] pub fn root(&self) -> &std::path::Path { &self.root } diff --git a/anneal/v2/src/util.rs b/anneal/v2/src/util.rs index 7aa461e978..a0c2716c1d 100644 --- a/anneal/v2/src/util.rs +++ b/anneal/v2/src/util.rs @@ -42,6 +42,7 @@ impl DirLock { /// /// Multiple processes can hold shared locks simultaneously, but an /// exclusive lock will block until all shared locks are released. + #[allow(dead_code)] pub(crate) fn lock_shared(path: std::path::PathBuf) -> anyhow::Result { let file = Self::open_lock_file(&path)?; file.lock_shared() diff --git a/anneal/v2/tests/integration.rs b/anneal/v2/tests/integration.rs new file mode 100644 index 0000000000..ac08c388ac --- /dev/null +++ b/anneal/v2/tests/integration.rs @@ -0,0 +1,128 @@ +// Copyright 2026 The Fuchsia Authors +// +// Licensed under the 2-Clause BSD License , Apache License, Version 2.0 +// , or the MIT +// license , at your option. +// This file may not be copied, modified, or distributed except according to +// those terms. + +#[cfg(feature = "exocrate_tests")] +fn cargo_anneal_bin_path() -> std::path::PathBuf { + std::env::var("CARGO_BIN_EXE_cargo-anneal") + .or_else(|_| std::env::var("CARGO_BIN_EXE_cargo_anneal")) + .expect("CARGO_BIN_EXE_* not set") + .into() +} + +#[cfg(feature = "exocrate_tests")] +fn cargo_anneal_command(bin_path: &std::path::Path) -> std::process::Command { + let mut cmd = std::process::Command::new(bin_path); + cmd.env_clear() + .env("__ANNEAL_LOCAL_DEV", "1") + .env("CARGO_MANIFEST_DIR", env!("CARGO_MANIFEST_DIR")); + cmd +} + +#[cfg(feature = "exocrate_tests")] +fn ensure_test_toolchain(bin_path: &std::path::Path) { + static SETUP_RESULT: std::sync::OnceLock> = std::sync::OnceLock::new(); + + let result = SETUP_RESULT.get_or_init(|| { + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let mut cmd = cargo_anneal_command(bin_path); + let output = cmd + .arg("setup") + .arg("--local-archive") + .arg(manifest_dir.join("target/anneal-exocrate.tar.zst")) + .output() + .map_err(|err| format!("failed to execute cargo-anneal setup: {err}"))?; + + if output.status.success() { + return Ok(()); + } + + Err(format!( + "cargo-anneal setup failed\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )) + }); + + if let Err(err) = result { + panic!("{err}"); + } +} + +#[cfg(feature = "exocrate_tests")] +#[test] +fn test_expand_subcommand_simple() { + let temp_dir = tempfile::tempdir().unwrap(); + let project_dir = temp_dir.path().join("project"); + let output_dir = temp_dir.path().join("llbc_out"); + std::fs::create_dir_all(project_dir.join("examples")).unwrap(); + std::fs::write( + project_dir.join("Cargo.toml"), + r#" + [package] + name = "test_proj" + version = "0.1.0" + edition = "2021" + + [[example]] + name = "simple" + path = "examples/simple.rs" + "#, + ) + .unwrap(); + std::fs::write( + project_dir.join("examples").join("simple.rs"), + r#" + pub fn add(left: usize, right: usize) -> usize { + left + right + } + + fn main() { + println!("Hello, world! {}", add(1, 2)); + } + "#, + ) + .unwrap(); + + let bin_path = cargo_anneal_bin_path(); + ensure_test_toolchain(&bin_path); + + let mut cmd = cargo_anneal_command(&bin_path); + if let Some(path) = std::env::var_os("PATH") { + cmd.env("PATH", path); + } + cmd.arg("expand") + .arg("--manifest-path") + .arg(project_dir.join("Cargo.toml")) + .arg("--example") + .arg("simple") + .arg("--output-dir") + .arg(&output_dir); + cmd.arg("--no-progress"); + + let output = cmd.output().expect("failed to execute cargo-anneal"); + + println!("stdout: {}", String::from_utf8_lossy(&output.stdout)); + println!("stderr: {}", String::from_utf8_lossy(&output.stderr)); + + assert!(output.status.success(), "cargo-anneal failed"); + + let mut found_llbc = false; + if output_dir.exists() { + for entry in std::fs::read_dir(&output_dir).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if path.is_file() && path.extension().map_or(false, |ext| ext == "llbc") { + found_llbc = true; + break; + } + } + } + + assert!(found_llbc, "No .llbc file found in output directory {:?}", output_dir); +} diff --git a/anneal/v2/tests/lock_integration.rs b/anneal/v2/tests/lock_integration.rs new file mode 100644 index 0000000000..e09cb02564 --- /dev/null +++ b/anneal/v2/tests/lock_integration.rs @@ -0,0 +1,167 @@ +// Copyright 2026 The Fuchsia Authors +// +// Licensed under the 2-Clause BSD License , Apache License, Version 2.0 +// , or the MIT +// license , at your option. +// This file may not be copied, modified, or distributed except according to +// those terms. + +#![cfg(feature = "exocrate_tests")] + +fn cargo_anneal_command(bin_path: &str) -> std::process::Command { + let mut cmd = std::process::Command::new(bin_path); + cmd.env_clear(); + cmd +} + +fn wait_with_timeout( + child: &mut std::process::Child, + timeout: std::time::Duration, +) -> anyhow::Result { + let start = std::time::Instant::now(); + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } + if start.elapsed() > timeout { + child.kill()?; + anyhow::bail!("Child process timed out after {:?}", timeout); + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } +} + +#[test] +fn test_dir_lock_coexistence_integration() { + let temp_dir = tempfile::tempdir().unwrap(); + let lock_dir = temp_dir.path().join("lock_dir"); + let log_file = temp_dir.path().join("concurrency_log.txt"); + let sig_file = temp_dir.path().join("sig_b_active.tmp"); + + let bin_path = std::env::var("CARGO_BIN_EXE_cargo-anneal") + .or_else(|_| std::env::var("CARGO_BIN_EXE_cargo_anneal")) + .expect("CARGO_BIN_EXE_* not set"); + + // 1. Spawn Reader A + let mut child_a = cargo_anneal_command(&bin_path) + .arg("test-lock-helper") + .arg("--role") + .arg("reader-a") + .arg("--lock-dir") + .arg(&lock_dir) + .arg("--log-file") + .arg(&log_file) + .arg("--sig-file") + .arg(&sig_file) + .spawn() + .expect("failed to spawn reader-a"); + + // Give A a tiny bit of time to acquire the lock. + std::thread::sleep(std::time::Duration::from_millis(100)); + + // 2. Spawn Reader B + let mut child_b = cargo_anneal_command(&bin_path) + .arg("test-lock-helper") + .arg("--role") + .arg("reader-b") + .arg("--lock-dir") + .arg(&lock_dir) + .arg("--log-file") + .arg(&log_file) + .arg("--sig-file") + .arg(&sig_file) + .spawn() + .expect("failed to spawn reader-b"); + + // 3. Wait for both with a strict timeout. + let status_a = wait_with_timeout(&mut child_a, std::time::Duration::from_secs(5)) + .expect("Reader A failed or timed out"); + let status_b = wait_with_timeout(&mut child_b, std::time::Duration::from_secs(5)) + .expect("Reader B failed or timed out"); + + assert!(status_a.success(), "Reader A exited with failure"); + assert!(status_b.success(), "Reader B exited with failure"); + + // 4. Read trace and assert overlap. + let trace = std::fs::read_to_string(&log_file).expect("failed to read log"); + let lines: Vec<&str> = trace.lines().collect(); + assert_eq!(lines.len(), 4, "Expected exactly 4 log lines"); + + // Expected overlap trace: + // SHARED_START_A + // SHARED_START_B + // SHARED_END_B + // SHARED_END_A + assert_eq!(lines[0], "SHARED_START_A"); + assert_eq!(lines[1], "SHARED_START_B"); + assert_eq!(lines[2], "SHARED_END_B"); + assert_eq!(lines[3], "SHARED_END_A"); +} + +#[test] +fn test_dir_lock_exclusion_integration() { + let temp_dir = tempfile::tempdir().unwrap(); + let lock_dir = temp_dir.path().join("lock_dir"); + let log_file = temp_dir.path().join("concurrency_log.txt"); + let sig_file = temp_dir.path().join("sig_b_attempt.tmp"); + + let bin_path = std::env::var("CARGO_BIN_EXE_cargo-anneal") + .or_else(|_| std::env::var("CARGO_BIN_EXE_cargo_anneal")) + .expect("CARGO_BIN_EXE_* not set"); + + // 1. Spawn Writer A + let mut child_a = cargo_anneal_command(&bin_path) + .arg("test-lock-helper") + .arg("--role") + .arg("writer-a") + .arg("--lock-dir") + .arg(&lock_dir) + .arg("--log-file") + .arg(&log_file) + .arg("--sig-file") + .arg(&sig_file) + .spawn() + .expect("failed to spawn writer-a"); + + // Give A a tiny bit of time to acquire the lock. + std::thread::sleep(std::time::Duration::from_millis(100)); + + // 2. Spawn Reader B + let mut child_b = cargo_anneal_command(&bin_path) + .arg("test-lock-helper") + .arg("--role") + .arg("reader-exclusion") + .arg("--lock-dir") + .arg(&lock_dir) + .arg("--log-file") + .arg(&log_file) + .arg("--sig-file") + .arg(&sig_file) + .spawn() + .expect("failed to spawn reader-exclusion"); + + // 3. Wait for both with a strict timeout. + let status_a = wait_with_timeout(&mut child_a, std::time::Duration::from_secs(5)) + .expect("Writer A failed or timed out"); + let status_b = wait_with_timeout(&mut child_b, std::time::Duration::from_secs(5)) + .expect("Reader B failed or timed out"); + + assert!(status_a.success(), "Writer A exited with failure"); + assert!(status_b.success(), "Reader B exited with failure"); + + // 4. Read trace and assert exclusion. + let trace = std::fs::read_to_string(&log_file).expect("failed to read log"); + let lines: Vec<&str> = trace.lines().collect(); + assert_eq!(lines.len(), 4, "Expected exactly 4 log lines"); + + // Expected sequential trace (since A blocks B): + // EXCLUSIVE_START_A + // EXCLUSIVE_END_A + // SHARED_START_B + // SHARED_END_B + assert_eq!(lines[0], "EXCLUSIVE_START_A"); + assert_eq!(lines[1], "EXCLUSIVE_END_A"); + assert_eq!(lines[2], "SHARED_START_B"); + assert_eq!(lines[3], "SHARED_END_B"); +}