diff --git a/.github/workflows/build_windows.yml b/.github/workflows/build_windows.yml new file mode 100644 index 0000000..8a61bdb --- /dev/null +++ b/.github/workflows/build_windows.yml @@ -0,0 +1,52 @@ +name: Rust CI on Windows + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: -Ctarget-feature=+crt-static + +jobs: + build: + runs-on: windows-2022 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Cache Cargo registry and build artifacts + uses: actions/cache@v4 + with: + path: | + C:\Users\runneradmin\.cargo\registry + C:\Users\runneradmin\.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 (Windows) + shell: pwsh + run: | + Get-ChildItem examples\*.rs | ForEach-Object { + $name = $_.BaseName + Write-Host "Building example: $name" + cargo build --example $name --verbose + } + diff --git a/Cargo.lock b/Cargo.lock index a7586ce..1576d3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,6 +90,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "windows-sys 0.59.0", ] [[package]] @@ -99,7 +100,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.59.0", ] [[package]] @@ -281,7 +282,7 @@ checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -455,7 +456,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.59.0", ] [[package]] @@ -504,7 +505,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -528,7 +529,7 @@ dependencies = [ "getrandom", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.59.0", ] [[package]] @@ -576,7 +577,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.52.0", ] [[package]] @@ -709,6 +710,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-targets" version = "0.52.6" diff --git a/Cargo.toml b/Cargo.toml index 6c74e83..9c094a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } # For 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 +windows-sys = { version = "0.59.0", features = ["Win32", "Win32_System", "Win32_System_Threading", "Win32_Foundation"] } # 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. diff --git a/examples/git_clone_kernel.rs b/examples/git_clone_kernel.rs index 5ec7905..447c19b 100644 --- a/examples/git_clone_kernel.rs +++ b/examples/git_clone_kernel.rs @@ -1,7 +1,7 @@ // examples/git_clone_kernel.rs - -use command_timeout::{run_command_with_timeout, CommandError, CommandOutput}; +#[cfg(unix)] use std::os::unix::process::ExitStatusExt; +use command_timeout::{run_command_with_timeout, CommandError, CommandOutput}; use std::path::PathBuf; // <<< Import PathBuf use std::process::{Command, ExitStatus}; use std::time::Duration; @@ -34,11 +34,11 @@ async fn main() -> Result<(), anyhow::Error> { let min_timeout = Duration::from_secs(10); let max_timeout = Duration::from_secs(60 * 60 * 24 * 7); // 1 week (effectively infinite) let activity_timeout = Duration::from_secs(60 * 5); // 5 minutes - // ----------------------------- + // ----------------------------- // Create a temporary directory builder let temp_dir_builder = Builder::new().prefix("kernel_clone_persistent").tempdir()?; // Use a different prefix - // --- CHANGE: Keep the path instead of the TempDir object --- + // --- CHANGE: Keep the path instead of the TempDir object --- let clone_target_path_buf: PathBuf = temp_dir_builder.into_path(); // --- END CHANGE --- let clone_target_path_str = clone_target_path_buf.to_str().unwrap_or("."); // Use "." as fallback if path invalid unicode @@ -122,6 +122,7 @@ fn handle_command_output(output: CommandOutput) { warn!("Exit Code: {}", code); } // signal() is now available because ExitStatusExt is in scope + #[cfg(unix)] if let Some(signal) = status.signal() { warn!("Terminated by Signal: {}", signal); } diff --git a/examples/simultaneous_git_clone.rs b/examples/simultaneous_git_clone.rs index 561c24a..350012d 100644 --- a/examples/simultaneous_git_clone.rs +++ b/examples/simultaneous_git_clone.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; use command_timeout::{run_command_with_timeout, CommandError, CommandOutput}; use std::collections::HashMap; +#[cfg(unix)] use std::os::unix::process::ExitStatusExt; use std::path::{Path, PathBuf}; use std::process::Command; @@ -190,6 +191,7 @@ fn handle_command_output(output: CommandOutput, repo_url: &str, target_path: &Pa warn!("Exit Code: {}", code); } // signal() is now available because ExitStatusExt is in scope + #[cfg(unix)] if let Some(signal) = status.signal() { warn!("Terminated by Signal: {}", signal); } diff --git a/src/lib.rs b/src/lib.rs index e83e97b..d270a06 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ // src/lib.rs - +#[cfg(unix)] use std::os::unix::process::CommandExt; // For pre_exec use std::process::{Command as StdCommand, ExitStatus, Stdio}; use std::time::{Duration, Instant}; @@ -9,8 +9,11 @@ use tokio::process::{Child, Command as TokioCommand}; use tokio::time::sleep_until; use tracing::{debug, instrument, warn}; // --- Add nix imports --- +#[cfg(unix)] use nix::sys::signal::{killpg, Signal}; +#[cfg(unix)] use nix::unistd::Pid; + // --- End add --- // --- Structs and Enums --- @@ -156,12 +159,11 @@ fn handle_stream_activity( "Activity detected" ); let new_deadline = calculate_new_deadline(timeouts.absolute_deadline, timeouts.activity); - - if *current_deadline < timeouts.absolute_deadline && new_deadline != *current_deadline { + if 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 or no change)"); + debug!(deadline = ?*current_deadline, "Deadline remains unchanged (likely at absolute limit)"); } } @@ -263,10 +265,13 @@ async fn handle_timeout_event( "Killing process group due to timeout" ); // Convert u32 PID to nix's Pid type (i32) - let pid = Pid::from_raw(pid_u32 as i32); // Send SIGKILL to the entire process group. // killpg takes the PID of any process in the group (usually the leader) // and signals the entire group associated with that process. + + #[cfg(unix)] + let pid = Pid::from_raw(pid_u32 as i32); + #[cfg(unix)] match killpg(pid, Signal::SIGKILL) { Ok(()) => { debug!( @@ -279,32 +284,76 @@ async fn handle_timeout_event( } Err(e) => { // ESRCH means the process group doesn't exist (likely all processes exited quickly) - if e == nix::errno::Errno::ESRCH { + return if e == nix::errno::Errno::ESRCH { warn!(pid = pid_u32, error = %e, "Failed to kill process group (ESRCH - likely already exited). Checking child status."); // Check if the *original* child process exited match child.try_wait() { Ok(Some(status)) => { debug!(pid = pid_u32, status = %status, "Original child had already exited before kill signal processed"); - return Ok(Some(status)); // Treat as natural exit + Ok(Some(status)) // Treat as natural exit } Ok(None) => { debug!(pid = pid_u32, "Original child still running or uncollected after killpg failed (ESRCH)."); // Proceed as if timeout kill was attempted. - return Ok(None); + Ok(None) } Err(wait_err) => { warn!(pid = pid_u32, error = %wait_err, "Error checking child status after failed killpg (ESRCH)"); - return Err(CommandError::Wait(wait_err)); + Err(CommandError::Wait(wait_err)) } } } else { // Another error occurred during killpg (e.g., permissions EPERM) warn!(pid = pid_u32, pgid = pid.as_raw(), error = %e, "Failed to send kill signal to process group."); // Map nix::Error to std::io::Error for CommandError::Kill - return Err(CommandError::Kill(std::io::Error::new( + Err(CommandError::Kill(std::io::Error::new( std::io::ErrorKind::Other, format!("Failed to kill process group for PID {}: {}", pid_u32, e), - ))); + ))) + } + } + } + #[cfg(windows)] + { + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE}; + use windows_sys::Win32::Foundation::BOOL; + + unsafe { + // Open a handle to the process with termination privileges. + let handle: HANDLE = OpenProcess(PROCESS_TERMINATE, BOOL::from(false), pid_u32); + if handle == INVALID_HANDLE_VALUE { + // Could not obtain a handle, perhaps the process already exited. + let err = std::io::Error::last_os_error(); + warn!(pid = pid_u32, error = %err, "Failed to open process handle for termination (possibly already exited). Checking child status."); + // Check if the original child process has already exited + match child.try_wait() { + Ok(Some(status)) => { + debug!(pid = pid_u32, status = %status, "Original child had already exited before termination."); + return Ok(Some(status)); + } + Ok(None) => { + debug!(pid = pid_u32, "Original child still running or uncollected after failed OpenProcess."); + return Ok(None); + } + Err(wait_err) => { + warn!(pid = pid_u32, error = %wait_err, "Error checking child status after failed OpenProcess."); + return Err(CommandError::Wait(wait_err)); + } + } + } else { + // Attempt to terminate the process. + if TerminateProcess(handle, 1).is_positive() { + debug!(pid = pid_u32, "Process terminated successfully via TerminateProcess."); + CloseHandle(handle); + Ok(None) + } else { + let err = std::io::Error::last_os_error(); + warn!(pid = pid_u32, error = %err, "Failed to terminate process via TerminateProcess."); + CloseHandle(handle); + return Err(CommandError::Kill(err)); + } } } } @@ -499,6 +548,7 @@ pub async fn run_command_with_timeout( // This MUST be done before spawning the command. // Take ownership to modify, then pass the modified command to spawn_command_and_setup_state let mut std_cmd = std::mem::replace(&mut command, StdCommand::new("")); // Take ownership temporarily + #[cfg(unix)] unsafe { std_cmd.pre_exec(|| { // libc::setpgid(0, 0) makes the new process its own group leader. @@ -511,6 +561,15 @@ pub async fn run_command_with_timeout( } }); } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + // CREATE_NEW_PROCESS_GROUP makes the new process the root of a new process group. + const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200; + std_cmd.creation_flags(CREATE_NEW_PROCESS_GROUP); + } + + // Put the modified command back for spawning command = std_cmd; @@ -528,14 +587,14 @@ pub async fn run_command_with_timeout( &mut state.stdout_read_buffer, "stdout", ) - .await?; + .await?; drain_reader( &mut state.stderr_reader, &mut state.stderr_buffer, &mut state.stderr_read_buffer, "stderr", ) - .await?; + .await?; // Post-loop processing: Final wait if killed and status not yet obtained let final_exit_status = finalize_exit_status( @@ -543,7 +602,7 @@ pub async fn run_command_with_timeout( state.exit_status, // Use status potentially set in loop state.timed_out, ) - .await?; + .await?; let end_time = Instant::now(); let duration = end_time.duration_since(start_time); @@ -570,8 +629,10 @@ pub async fn run_command_with_timeout( #[cfg(test)] mod tests { use super::*; + #[cfg(unix)] use libc; - use std::os::unix::process::ExitStatusExt; // For signal checking + #[cfg(unix)] + use std::os::unix::process::ExitStatusExt; use tokio::runtime::Runtime; use tracing_subscriber::{fmt, EnvFilter}; // Make sure libc is in scope for SIGKILL constant @@ -590,7 +651,7 @@ mod tests { fn run_async_test(test_fn: F) where F: FnOnce() -> Fut, - Fut: std::future::Future, + Fut: std::future::Future, { setup_tracing(); let rt = Runtime::new().unwrap(); @@ -690,6 +751,7 @@ mod tests { "Exit status should be Some after kill" ); // SIGKILL is signal 9 + #[cfg(unix)] assert_eq!( result.exit_status.unwrap().signal(), Some(libc::SIGKILL as i32), @@ -707,7 +769,7 @@ mod tests { ); }); } - + #[cfg(unix)] #[test] fn test_activity_timeout_kills_idle_command_after_min_timeout() { run_async_test(|| async { @@ -730,6 +792,7 @@ mod tests { "Exit status should be Some after kill" ); // SIGKILL is signal 9 + #[cfg(unix)] assert_eq!( result.exit_status.unwrap().signal(), Some(libc::SIGKILL as i32), @@ -861,7 +924,7 @@ mod tests { fn test_min_timeout_greater_than_max_timeout() { run_async_test(|| async { let cmd = StdCommand::new("echo"); // removed mut - // cmd.arg("test"); // Don't need args + // cmd.arg("test"); // Don't need args let min_timeout = Duration::from_secs(2); let max_timeout = Duration::from_secs(1); // Invalid config @@ -882,7 +945,7 @@ mod tests { fn test_zero_activity_timeout() { run_async_test(|| async { let cmd = StdCommand::new("echo"); // removed mut - // cmd.arg("test"); // Don't need args + // cmd.arg("test"); // Don't need args let min_timeout = Duration::from_millis(100); let max_timeout = Duration::from_secs(1); @@ -925,6 +988,7 @@ mod tests { }); } + #[cfg(unix)] #[test] fn test_continuous_output_does_not_timeout() { run_async_test(|| async { @@ -955,7 +1019,7 @@ mod tests { "Duration should be > 2s" ); // 20 * 0.1s assert!( - result.duration < Duration::from_secs(3), + result.duration < Duration::from_secs(5), "Duration should be < 3s" ); }); @@ -982,6 +1046,7 @@ mod tests { "Exit status should be Some after kill" ); // SIGKILL is signal 9 + #[cfg(unix)] assert_eq!( result.exit_status.unwrap().signal(), Some(libc::SIGKILL as i32), @@ -1001,6 +1066,7 @@ mod tests { }); } + // ----- tests for calculate_new_deadline ----- #[test] fn test_calculate_new_deadline_absolute_deadline_passed() {