From 1997bdc2387052273a5098f7ba27840964dd4470 Mon Sep 17 00:00:00 2001 From: Deepnarayan Sett Date: Tue, 1 Apr 2025 11:46:15 +0530 Subject: [PATCH 1/2] Dividing the Actions PR - Mac --- .github/workflows/{build_linux.yml => build_mac.yml} | 4 ++-- src/lib.rs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) rename .github/workflows/{build_linux.yml => build_mac.yml} (93%) diff --git a/.github/workflows/build_linux.yml b/.github/workflows/build_mac.yml similarity index 93% rename from .github/workflows/build_linux.yml rename to .github/workflows/build_mac.yml index 1e90ff5..04e2520 100644 --- a/.github/workflows/build_linux.yml +++ b/.github/workflows/build_mac.yml @@ -1,4 +1,4 @@ -name: Rust CI on Linux +name: Rust CI on Mac on: push: @@ -12,7 +12,7 @@ env: jobs: build: - runs-on: ubuntu-latest + runs-on: macos-latest steps: - uses: actions/checkout@v4 diff --git a/src/lib.rs b/src/lib.rs index 0edce1d..eb5ab29 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -948,8 +948,8 @@ mod tests { "Duration should be > 2s" ); // 20 * 0.1s assert!( - result.duration < Duration::from_secs(3), - "Duration should be < 3s" + result.duration < Duration::from_secs(5), + "Duration should be < 5s" ); }); } From c3e48e5610f5b46999ec57acfa4712b22842ef69 Mon Sep 17 00:00:00 2001 From: Deepnarayan Sett Date: Sat, 5 Apr 2025 23:28:57 +0530 Subject: [PATCH 2/2] Implemented Github Actions for Mac and Added Mac Compatibility --- .github/workflows/build_linux.yml | 49 ++++++ .github/workflows/build_mac.yml | 9 ++ Cargo.lock | 105 +++++++++++++ Cargo.toml | 11 +- examples/simultaneous_git_clone.rs | 236 +++++++++++++++++++++++++++++ src/lib.rs | 146 +++++++++++++++++- 6 files changed, 550 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/build_linux.yml create mode 100644 examples/simultaneous_git_clone.rs diff --git a/.github/workflows/build_linux.yml b/.github/workflows/build_linux.yml new file mode 100644 index 0000000..a512ff7 --- /dev/null +++ b/.github/workflows/build_linux.yml @@ -0,0 +1,49 @@ +name: Rust CI on Linux + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Cache Cargo registry and build artifacts + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Set up Rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + + - name: Build + run: cargo build --verbose + + - name: Run tests + run: cargo test --verbose + + - name: Build examples + run: | + for example in $(ls examples/*.rs); do + name=$(basename "$example" .rs) + echo "Building example: $name" + cargo build --example "$name" --verbose + done + diff --git a/.github/workflows/build_mac.yml b/.github/workflows/build_mac.yml index 04e2520..cd4ab91 100644 --- a/.github/workflows/build_mac.yml +++ b/.github/workflows/build_mac.yml @@ -38,3 +38,12 @@ jobs: - name: Run tests run: cargo test --verbose + + - name: Build examples + run: | + for example in $(ls examples/*.rs); do + name=$(basename "$example" .rs) + echo "Building example: $name" + cargo build --example "$name" --verbose + done + diff --git a/Cargo.lock b/Cargo.lock index edd49f2..eed5c0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,7 @@ name = "command_timeout" version = "0.1.2" dependencies = [ "anyhow", + "futures", "libc", "nix", "tempfile", @@ -107,6 +108,95 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + [[package]] name = "getrandom" version = "0.3.2" @@ -266,6 +356,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "proc-macro2" version = "1.0.94" @@ -386,6 +482,15 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + [[package]] name = "smallvec" version = "1.14.0" diff --git a/Cargo.toml b/Cargo.toml index 721a783..8a5ac85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,11 +17,11 @@ tracing = "0.1" thiserror = "1.0" nix = { version = "0.29", features = ["signal", "process"] } # For killpg libc = "0.2" # For setpgid and signal constants - # Dependencies also used by the example(s) tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } # For logging setup tempfile = "3" # For creating temporary directories in examples/tests anyhow = "1.0" # For simple error handling in examples +futures = "0.3" # For simulataneous_git_clone example # Dependency on the library itself (needed for building examples within the workspace) # This line might not be strictly necessary if cargo automatically detects it, # but explicitly listing it can sometimes help. @@ -39,4 +39,11 @@ anyhow = "1.0" # For simple error handling in examples name = "git_clone_kernel" path = "examples/git_clone_kernel.rs" # Example requires tokio runtime features if not enabled globally in [dependencies] -# required-features = ["full"] # Uncomment if tokio features are restricted in [dependencies] \ No newline at end of file +# required-features = ["full"] # Uncomment if tokio features are restricted in [dependencies] + +# Declare the example binary target +[[example]] +name = "simultaneous_git_clone" +path = "examples/simultaneous_git_clone.rs" +# Example requires tokio runtime features if not enabled globally in [dependencies] +# required-features = ["full"] # uncomment if tokio features are restricted in [dependencies] \ No newline at end of file diff --git a/examples/simultaneous_git_clone.rs b/examples/simultaneous_git_clone.rs new file mode 100644 index 0000000..561c24a --- /dev/null +++ b/examples/simultaneous_git_clone.rs @@ -0,0 +1,236 @@ +use anyhow::{Context, Result}; +use command_timeout::{run_command_with_timeout, CommandError, CommandOutput}; +use std::collections::HashMap; +use std::os::unix::process::ExitStatusExt; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; +use tempfile::Builder; +use tracing::{error, info, warn, Level}; +use tracing_subscriber::FmtSubscriber; + +// Define the Git repository URL +const CCXR: &str = "https://github.com/CCExtractor/ccextractor.git"; +const SAMPLE_PLATFORM: &str = "https://github.com/CCExtractor/sample-platform.git"; +const FLUTTERGUI: &str = "https://github.com/CCExtractor/ccextractorfluttergui.git"; + +// Run like this: +// RUST_LOG=debug cargo run --example simultaneous_git_clone + +#[tokio::main] +async fn main() -> Result<(), anyhow::Error> { + // Using anyhow for simple example error handling + // Initialize tracing subscriber + let subscriber = FmtSubscriber::builder() + .with_max_level(Level::INFO) // Default to INFO level + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) // Allow RUST_LOG override + .finish(); + tracing::subscriber::set_global_default(subscriber) + .expect("Setting default tracing subscriber failed"); + + info!("Starting simultaneous git clone example..."); + + // --- Timeout Configuration --- + let repos = vec![ + ( + CCXR, + Duration::from_secs(10), + Duration::from_secs(300), + Duration::from_secs(60), + ), + ( + SAMPLE_PLATFORM, + Duration::from_secs(15), + Duration::from_secs(600), + Duration::from_secs(120), + ), + ( + FLUTTERGUI, + Duration::from_secs(20), + Duration::from_secs(900), + Duration::from_secs(180), + ), + ]; + info!("Configured repositories and timeouts:"); + // ----------------------------- + + //Print repos and their timeouts + for (url, min, max, activity) in &repos { + info!( + "Repo: '{}', Min Timeout: {:?}, Max Timeout: {:?}, Activity Timeout: {:?}", + url, min, max, activity + ); + } + + // Create a temp directories to clone into + let mut results_summary: HashMap<&str, Result<(), anyhow::Error>> = HashMap::new(); + let clone_futures = repos.iter().map(|(url, min, max, activity)| { + let url_clone = *url; // Clone the URL for use in the async block + async move { + let dir = Builder::new() + .prefix(&format!( + "clone_{}", + url.split('/').last().unwrap_or("repo") + )) + .tempdir() + .context("Failed to create temporary directory"); + + let result = match dir { + Ok(dir) => clone_repository(url, dir.path(), *min, *max, *activity).await, + Err(e) => Err(e), + }; + (url_clone, result) + } + }); + + // Run all clone operations concurrently + let results = futures::future::join_all(clone_futures).await; + + // Collect results into the summary + for (url, result) in results { + results_summary.insert(url, result); + } + + // Log the summary of results + info!("Clone operations summary:"); + for (url, result) in &results_summary { + match result { + Ok(_) => info!("SUCCESS: {}", url), + Err(e) => error!("FAILED: {} - {:?}", url, e), + } + } + + // Log overall status + let failed_count = results_summary.values().filter(|r| r.is_err()).count(); + if failed_count > 0 { + error!("{} repositories failed to clone.", failed_count); + } else { + info!("All repositories cloned successfully!"); + } + + Ok(()) +} + +// Prepare the git clone command +async fn clone_repository( + repo_url: &str, + target_path: &Path, + min_timeout: Duration, + max_timeout: Duration, + activity_timeout: Duration, +) -> Result<(), anyhow::Error> { + // Using anyhow for simple example error handling + + //log clone initialization with timeouts + info!( + "Preparing to clone '{}' into directory '{}'", + repo_url, + target_path.display() + ); + info!( + "Timeouts: min={:?}, max={:?}, activity={:?}", + min_timeout, max_timeout, activity_timeout + ); + + //build git clone command + let mut cmd = Command::new("git"); + cmd.arg("clone") + .arg("--progress") // Explicitly ask for progress output on stderr + .arg(repo_url) + .arg(target_path); // Clone into the target path + + // Run the command using the library function + let result = run_command_with_timeout(cmd, min_timeout, max_timeout, activity_timeout).await; + + //error handling + match result { + Ok(output) => { + handle_command_output(output, repo_url, &target_path.to_path_buf()); + } + Err(e) => { + // Log specific details first + match &e { + // Borrow e here to allow using it later + CommandError::Spawn(io_err) => error!("Failed to spawn git: {}", io_err), + CommandError::Io(io_err) => error!("IO error reading output: {}", io_err), + CommandError::Kill(io_err) => error!("Error sending kill signal: {}", io_err), + CommandError::Wait(io_err) => error!("Error waiting for command exit: {}", io_err), + // Use 'ref msg' to borrow the string instead of moving it + CommandError::InvalidTimeout(ref msg) => error!("Invalid timeout config: {}", msg), + CommandError::StdoutPipe => error!("Failed to get stdout pipe from command"), + CommandError::StderrPipe => error!("Failed to get stderr pipe from command"), + } + error!("Command execution failed."); // General failure message + + // Print path even on error + warn!( + "Clone operation failed. Directory may be incomplete or empty: {}", + target_path.display() + ); + + // Now convert the original error (which is still valid) and return + return Err(e.into()); + } + } + + Ok(()) +} + +fn handle_command_output(output: CommandOutput, repo_url: &str, target_path: &PathBuf) { + info!("Finished cloning '{}'.", repo_url); + info!("Total Duration: {:?}", output.duration); + info!("Timed Out: {}", output.timed_out); + + if let Some(status) = output.exit_status { + if status.success() { + info!("Exit Status: {} (Success)", status); + } else { + warn!("Exit Status: {} (Failure)", status); + if let Some(code) = status.code() { + warn!("Exit Code: {}", code); + } + // signal() is now available because ExitStatusExt is in scope + if let Some(signal) = status.signal() { + warn!("Terminated by Signal: {}", signal); + } + } + } else { + warn!("Exit Status: None (Killed by timeout, status unavailable?)"); + } + + info!("Stdout Length: {} bytes", output.stdout.len()); + if !output.stdout.is_empty() { + // Print snippet or full output (be cautious with large output) + info!( + "Stdout (first 1KB):\n---\n{}...\n---", + String::from_utf8_lossy(&output.stdout.iter().take(1024).cloned().collect::>()) + ); + } + + info!("Stderr Length: {} bytes", output.stderr.len()); + if !output.stderr.is_empty() { + // Git clone progress usually goes to stderr + warn!( + "Stderr (first 1KB):\n---\n{}...\n---", + String::from_utf8_lossy(&output.stderr.iter().take(1024).cloned().collect::>()) + ); + // For full stderr: warn!("Stderr:\n{}", String::from_utf8_lossy(&output.stderr)); + } + + if output.exit_status.map_or(false, |s| s.success()) && !output.timed_out { + info!("---> Clone completed successfully for '{}'! <---", repo_url); + } else if output.timed_out { + error!("---> Clone FAILED due to timeout for '{}'! <---", repo_url); + } else { + error!( + "---> Clone FAILED with non-zero exit status for '{}'! <---", + repo_url + ); + } + + //log success with directory location for each clone + info!( + "Clone operation finished. Directory location: {}", + target_path.display() + ); +} diff --git a/src/lib.rs b/src/lib.rs index eb5ab29..ef2fc34 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -156,11 +156,12 @@ fn handle_stream_activity( "Activity detected" ); let new_deadline = calculate_new_deadline(timeouts.absolute_deadline, timeouts.activity); - if new_deadline != *current_deadline { + + if *current_deadline < timeouts.absolute_deadline && new_deadline != *current_deadline { debug!(old = ?*current_deadline, new = ?new_deadline, "Updating deadline"); *current_deadline = new_deadline; } else { - debug!(deadline = ?*current_deadline, "Deadline remains unchanged (likely at absolute limit)"); + debug!(deadline = ?*current_deadline, "Deadline remains unchanged (likely at absolute limit or no change)"); } } @@ -947,10 +948,16 @@ mod tests { result.duration > Duration::from_secs(2), "Duration should be > 2s" ); // 20 * 0.1s + #[cfg(not(target_os = "macos"))] assert!( - result.duration < Duration::from_secs(5), - "Duration should be < 5s" + result.duration < Duration::from_secs(3), + "Duration should be < 3s" ); + #[cfg(target_os = "macos")] + assert!( + result.duration < Duration::from_secs(4), + "Duration should be < 4s" + ); // macOS has longer sleep resolution }); } @@ -993,4 +1000,135 @@ mod tests { ); }); } + + // ----- tests for calculate_new_deadline ----- + #[test] +fn test_calculate_new_deadline_absolute_deadline_passed() { + let absolute_deadline = Instant::now() - Duration::from_secs(1); // Already passed + let activity_timeout = Duration::from_secs(5); + + let new_deadline = calculate_new_deadline(absolute_deadline, activity_timeout); + + assert_eq!( + new_deadline, absolute_deadline, + "New deadline should be the absolute deadline when it has already passed" + ); +} + +#[test] +fn test_calculate_new_deadline_activity_timeout_before_absolute_deadline() { + let absolute_deadline = Instant::now() + Duration::from_secs(10); + let activity_timeout = Duration::from_secs(5); + + let new_deadline = calculate_new_deadline(absolute_deadline, activity_timeout); + + assert!( + new_deadline <= absolute_deadline, + "New deadline should not exceed the absolute deadline" + ); + assert!( + new_deadline > Instant::now(), + "New deadline should be in the future" + ); +} + // ----- tests for handle_stream_activity ----- + #[test] +fn test_handle_stream_activity_updates_deadline() { + let mut current_deadline = Instant::now() + Duration::from_secs(5); + let timeouts = TimeoutConfig { + minimum: Duration::from_secs(1), + maximum: Duration::from_secs(10), + activity: Duration::from_secs(3), + start_time: Instant::now(), + absolute_deadline: Instant::now() + Duration::from_secs(10), + }; + + handle_stream_activity(10, "stdout", &mut current_deadline, &timeouts); + + assert!( + current_deadline > Instant::now(), + "Current deadline should be updated to a future time" + ); + assert!( + current_deadline <= timeouts.absolute_deadline, + "Current deadline should not exceed the absolute deadline" + ); +} + +#[test] +fn test_handle_stream_activity_no_update_at_absolute_limit() { + let absolute_deadline = Instant::now() + Duration::from_secs(5); + let mut current_deadline = absolute_deadline; // Already at the absolute limit + let timeouts = TimeoutConfig { + minimum: Duration::from_secs(1), + maximum: Duration::from_secs(10), + activity: Duration::from_secs(3), + start_time: Instant::now(), + absolute_deadline, + }; + + handle_stream_activity(10, "stderr", &mut current_deadline, &timeouts); + + assert_eq!( + current_deadline, absolute_deadline, + "Current deadline should remain unchanged when at the absolute limit" + ); +} + + // ----- tests for run_command_loop ----- +#[test] +fn test_run_command_loop_exits_on_process_finish() { + run_async_test(|| async { + let mut cmd = StdCommand::new("echo"); + cmd.arg("Test"); + + let timeouts = TimeoutConfig { + minimum: Duration::from_secs(1), + maximum: Duration::from_secs(5), + activity: Duration::from_secs(2), + start_time: Instant::now(), + absolute_deadline: Instant::now() + Duration::from_secs(5), + }; + + let mut state = spawn_command_and_setup_state(&mut cmd, timeouts.absolute_deadline) + .expect("Failed to spawn command"); + + let result = run_command_loop(&mut state, &timeouts).await; + + assert!(result.is_ok(), "Command loop should exit without errors"); + assert!( + state.exit_status.is_some(), + "Exit status should be set when process finishes naturally" + ); + }); +} + +#[test] +fn test_run_command_loop_exits_on_timeout() { + run_async_test(|| async { + let mut cmd = StdCommand::new("sleep"); + cmd.arg("5"); + + let timeouts = TimeoutConfig { + minimum: Duration::from_secs(1), + maximum: Duration::from_secs(2), // Short timeout + activity: Duration::from_secs(10), + start_time: Instant::now(), + absolute_deadline: Instant::now() + Duration::from_secs(2), + }; + + let mut state = spawn_command_and_setup_state(&mut cmd, timeouts.absolute_deadline) + .expect("Failed to spawn command"); + + let result = run_command_loop(&mut state, &timeouts).await; + + assert!(result.is_ok(), "Command loop should exit without errors"); + assert!( + state.exit_status.is_none(), + "Exit status should be None when process is killed due to timeout" + ); + assert!(state.timed_out, "State should indicate that the process timed out"); + }); +} + } // end tests mod