From 5101d9c410a7c901ea20636d2a4e56b3282a1c14 Mon Sep 17 00:00:00 2001 From: diogo464 Date: Thu, 19 Jun 2025 10:03:56 +0100 Subject: Add wait subcommand for blocking until process termination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a new 'wait' subcommand that blocks until a specified daemon process terminates, with configurable timeout and polling interval. Features: - Default 30-second timeout, configurable with --timeout flag - Infinite wait with --timeout 0 - Configurable polling interval with --interval flag (default 1 second) - Quiet operation - only shows errors on failure - Preserves PID files (doesn't clean up) - Exit codes: 0 for success, 1 for failure Usage examples: - demon wait my-process # Wait 30s - demon wait my-process --timeout 0 # Wait indefinitely - demon wait my-process --timeout 60 --interval 2 # Custom timeout/interval Added comprehensive test suite covering: - Non-existent processes - Already terminated processes - Normal process termination - Timeout scenarios - Infinite timeout behavior - Custom polling intervals Updated documentation: - README.md with wait command reference and usage examples - LLM guide with detailed wait command documentation - Integration examples for development workflows 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- IMPLEMENTATION_PLAN.md | 140 ++++++++++++++++++++++++++++++++++++++++++++++++- README.md | 24 +++++++-- src/main.rs | 100 +++++++++++++++++++++++++++++++++++ tests/cli.rs | 123 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 382 insertions(+), 5 deletions(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index eaa1e64..56c3caf 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -172,4 +172,142 @@ The implementation follows the planned modular structure: - **File Operations**: Manages PID files and log redirection ✅ - **Output Display**: Implements both cat and tail functionality ✅ -The tool is ready for production use! \ No newline at end of file +--- + +# Wait Subcommand Implementation Plan + +## Overview +Add a `wait` subcommand to the demon CLI that blocks until a specified process terminates, with configurable timeout and polling interval. + +## Requirements Summary +- **Default timeout**: 30 seconds +- **Infinite timeout**: Use `--timeout 0` +- **Exit codes**: 0 for success, 1 for failure +- **PID file**: Leave untouched (don't clean up) +- **Output**: Quiet operation, only show error messages +- **Polling interval**: 1 second default, configurable with `--interval` flag + +## Implementation Details + +### 1. Command Structure +```rust +/// Wait for a daemon process to terminate +Wait(WaitArgs), +``` + +### 2. Arguments Structure +```rust +#[derive(Args)] +struct WaitArgs { + /// Process identifier + id: String, + + /// Timeout in seconds (0 = infinite) + #[arg(long, default_value = "30")] + timeout: u64, + + /// Polling interval in seconds + #[arg(long, default_value = "1")] + interval: u64, +} +``` + +### 3. Core Function Implementation +```rust +fn wait_daemon(id: &str, timeout: u64, interval: u64) -> Result<()> { + // 1. Check if PID file exists + // 2. Read PID from file + // 3. Check if process exists initially + // 4. If timeout == 0, loop indefinitely + // 5. Otherwise, loop with timeout tracking + // 6. Poll every `interval` seconds + // 7. Return appropriate exit codes +} +``` + +### 4. Logic Flow +1. **Initial validation**: + - Check if PID file exists → error if not + - Read PID from file → error if invalid + - Check if process is running → error if already dead + +2. **Waiting loop**: + - If timeout = 0: infinite loop + - Otherwise: track elapsed time + - Poll every `interval` seconds using `is_process_running_by_pid()` + - Break when process terminates or timeout reached + +3. **Exit conditions**: + - Process terminates → exit 0 + - Timeout reached → error message + exit 1 + - Initial errors → error message + exit 1 + +### 5. Error Messages +- "Process '{id}' not found (no PID file)" +- "Process '{id}' is not running" +- "Timeout reached waiting for process '{id}' to terminate" + +## Testing Strategy + +### New Tests +1. **test_wait_nonexistent_process**: Should fail with appropriate error +2. **test_wait_already_dead_process**: Should fail when process already terminated +3. **test_wait_process_terminates**: Should succeed when process terminates normally +4. **test_wait_timeout**: Should fail when timeout is reached +5. **test_wait_infinite_timeout**: Test with timeout=0 (use short-lived process) +6. **test_wait_custom_interval**: Test with different polling intervals + +### Updated Tests +Replace `std::thread::sleep(Duration::from_millis(100))` with `demon wait` in: +- `test_run_creates_files` → `demon wait test --timeout 5` +- `test_run_with_complex_command` → `demon wait complex --timeout 5` +- Similar tests that wait for process completion + +## Files to Modify + +### 1. src/main.rs +- Add `Wait(WaitArgs)` to `Commands` enum (around line 146) +- Add `WaitArgs` struct after other Args structs (around line 206) +- Add `Commands::Wait(args) => wait_daemon(&args.id, args.timeout, args.interval)` to match statement (around line 246) +- Implement `wait_daemon()` function (add after other daemon functions) + +### 2. tests/cli.rs +- Add new test functions for wait subcommand +- Update existing tests to use wait instead of sleep where appropriate + +### 3. README.md +- Add wait command to command reference section +- Add examples showing wait usage + +### 4. LLM Guide (print_llm_guide function) +- Add wait command documentation +- Add to available commands list +- Add usage examples + +## Command Usage Examples + +```bash +# Wait with default 30s timeout +demon wait my-process + +# Wait indefinitely +demon wait my-process --timeout 0 + +# Wait with custom timeout and interval +demon wait my-process --timeout 60 --interval 2 +``` + +## Implementation Order +1. Implement core functionality in main.rs +2. Add comprehensive tests +3. Update existing tests to use wait +4. Update documentation (README + LLM guide) +5. Test full integration + +## Key Implementation Notes +- Use existing `is_process_running_by_pid()` function for consistency +- Use existing `PidFile::read_from_file()` for PID file handling +- Follow existing error handling patterns with anyhow +- Use `std::thread::sleep(Duration::from_secs(interval))` for polling +- Track elapsed time for timeout implementation +- Maintain quiet operation - no progress messages \ No newline at end of file diff --git a/README.md b/README.md index cbc8fe5..99fb135 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,20 @@ demon cat web-server demon cat web-server --stdout ``` +### `demon wait [--timeout ] [--interval ]` +Wait for a daemon process to terminate. + +```bash +# Wait with default 30-second timeout +demon wait web-server + +# Wait indefinitely +demon wait web-server --timeout 0 + +# Wait with custom timeout and polling interval +demon wait web-server --timeout 60 --interval 2 +``` + ### `demon clean` Remove orphaned files from processes that are no longer running. @@ -145,6 +159,9 @@ demon run db-server docker run -p 5432:5432 postgres # Monitor everything demon list demon tail api-server --stderr # Watch for errors + +# Wait for a specific service to finish +demon wait api-server ``` ### LLM Agent Integration @@ -154,6 +171,9 @@ Designed for seamless automation and LLM agent workflows: # Agents can start long-running processes demon run data-processor python process_large_dataset.py +# Wait for the process to complete +demon wait data-processor --timeout 3600 # 1 hour timeout + # Check status programmatically if demon status data-processor | grep -q "RUNNING"; then echo "Processing is still running" @@ -223,9 +243,7 @@ demon list --quiet demon list --quiet | grep -q "web-server:" || demon run web-server python -m http.server # Wait for process to finish -while demon list --quiet | grep -q "backup-job:.*:RUNNING"; do - sleep 5 -done +demon wait backup-job --timeout 0 # Wait indefinitely # Get all running processes demon list --quiet | grep ":RUNNING" | cut -d: -f1 diff --git a/src/main.rs b/src/main.rs index e90bfed..5547d9a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -142,6 +142,9 @@ enum Commands { /// Output comprehensive usage guide for LLMs Llm, + + /// Wait for a daemon process to terminate + Wait(WaitArgs), } #[derive(Args)] @@ -204,6 +207,20 @@ struct StatusArgs { id: String, } +#[derive(Args)] +struct WaitArgs { + /// Process identifier + id: String, + + /// Timeout in seconds (0 = infinite) + #[arg(long, default_value = "30")] + timeout: u64, + + /// Polling interval in seconds + #[arg(long, default_value = "1")] + interval: u64, +} + fn main() { tracing_subscriber::fmt() .with_writer(std::io::stderr) @@ -243,6 +260,7 @@ fn run_command(command: Commands) -> Result<()> { print_llm_guide(); Ok(()) } + Commands::Wait(args) => wait_daemon(&args.id, args.timeout, args.interval), } } @@ -954,6 +972,28 @@ demon tail web-server # Follow both logs demon tail web-server --stdout # Follow only stdout ``` +### demon wait [--timeout ] [--interval ] +Blocks until a daemon process terminates. + +**Syntax**: `demon wait [--timeout ] [--interval ]` + +**Behavior**: +- Checks if PID file exists and process is running +- Polls the process every `interval` seconds (default: 1 second) +- Waits for up to `timeout` seconds (default: 30 seconds) +- Use `--timeout 0` for infinite wait +- Exits successfully when process terminates +- Fails with error if process doesn't exist or timeout is reached +- Does not clean up PID files (use `demon clean` for that) + +**Examples**: +```bash +demon wait web-server # Wait 30s for termination +demon wait backup-job --timeout 0 # Wait indefinitely +demon wait data-processor --timeout 3600 # Wait up to 1 hour +demon wait short-task --interval 2 # Poll every 2 seconds +``` + ### demon clean Removes orphaned files from processes that are no longer running. @@ -996,6 +1036,13 @@ demon status my-web-server # Check if it started demon tail my-web-server # Monitor logs ``` +### Waiting for Process Completion +```bash +demon run batch-job python process_data.py +demon wait batch-job --timeout 600 # Wait up to 10 minutes +demon cat batch-job # Check output after completion +``` + ### Running a Backup Job ```bash demon run nightly-backup -- rsync -av /data/ /backup/ @@ -1059,6 +1106,59 @@ This tool is designed for Linux environments and provides a simple interface for ); } +fn wait_daemon(id: &str, timeout: u64, interval: u64) -> Result<()> { + let pid_file = format!("{}.pid", id); + + // Check if PID file exists and read PID data + let pid_file_data = match PidFile::read_from_file(&pid_file) { + Ok(data) => data, + Err(PidFileReadError::FileNotFound) => { + return Err(anyhow::anyhow!("Process '{}' not found (no PID file)", id)); + } + Err(PidFileReadError::FileInvalid(reason)) => { + return Err(anyhow::anyhow!("Process '{}' has invalid PID file: {}", id, reason)); + } + Err(PidFileReadError::IoError(err)) => { + return Err(anyhow::anyhow!("Failed to read PID file for '{}': {}", id, err)); + } + }; + + let pid = pid_file_data.pid; + + // Check if process is currently running + if !is_process_running_by_pid(pid) { + return Err(anyhow::anyhow!("Process '{}' is not running", id)); + } + + tracing::info!("Waiting for process '{}' (PID: {}) to terminate", id, pid); + + // Handle infinite timeout case + if timeout == 0 { + loop { + if !is_process_running_by_pid(pid) { + tracing::info!("Process '{}' (PID: {}) has terminated", id, pid); + return Ok(()); + } + thread::sleep(Duration::from_secs(interval)); + } + } + + // Handle timeout case + let mut elapsed = 0; + while elapsed < timeout { + if !is_process_running_by_pid(pid) { + tracing::info!("Process '{}' (PID: {}) has terminated", id, pid); + return Ok(()); + } + + thread::sleep(Duration::from_secs(interval)); + elapsed += interval; + } + + // Timeout reached + Err(anyhow::anyhow!("Timeout reached waiting for process '{}' to terminate", id)) +} + fn find_pid_files() -> Result> { let entries = std::fs::read_dir(".")? .filter_map(|entry| { diff --git a/tests/cli.rs b/tests/cli.rs index 5e72dad..285eb70 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -17,7 +17,8 @@ fn test_help_output() { .stdout(predicate::str::contains("cat")) .stdout(predicate::str::contains("list")) .stdout(predicate::str::contains("status")) - .stdout(predicate::str::contains("clean")); + .stdout(predicate::str::contains("clean")) + .stdout(predicate::str::contains("wait")); } #[test] @@ -410,7 +411,127 @@ fn test_llm_command() { .stdout(predicate::str::contains("demon cat")) .stdout(predicate::str::contains("demon status")) .stdout(predicate::str::contains("demon clean")) + .stdout(predicate::str::contains("demon wait")) .stdout(predicate::str::contains("Common Workflows")) .stdout(predicate::str::contains("Best Practices")) .stdout(predicate::str::contains("Integration Tips")); } + +#[test] +fn test_wait_nonexistent_process() { + let temp_dir = TempDir::new().unwrap(); + + let mut cmd = Command::cargo_bin("demon").unwrap(); + cmd.current_dir(temp_dir.path()) + .args(&["wait", "nonexistent"]) + .assert() + .failure() + .stderr(predicate::str::contains("not found")); +} + +#[test] +fn test_wait_already_dead_process() { + let temp_dir = TempDir::new().unwrap(); + + // Create a short-lived process + let mut cmd = Command::cargo_bin("demon").unwrap(); + cmd.current_dir(temp_dir.path()) + .args(&["run", "dead", "echo", "hello"]) + .assert() + .success(); + + // Give it time to finish + std::thread::sleep(Duration::from_millis(100)); + + // Try to wait for it (should fail since it's already dead) + let mut cmd = Command::cargo_bin("demon").unwrap(); + cmd.current_dir(temp_dir.path()) + .args(&["wait", "dead"]) + .assert() + .failure() + .stderr(predicate::str::contains("not running")); +} + +#[test] +fn test_wait_process_terminates() { + let temp_dir = TempDir::new().unwrap(); + + // Start a process that will run for 2 seconds + let mut cmd = Command::cargo_bin("demon").unwrap(); + cmd.current_dir(temp_dir.path()) + .args(&["run", "short", "sleep", "2"]) + .assert() + .success(); + + // Wait for it with a 5-second timeout (should succeed) + let mut cmd = Command::cargo_bin("demon").unwrap(); + cmd.current_dir(temp_dir.path()) + .args(&["wait", "short", "--timeout", "5"]) + .assert() + .success(); +} + +#[test] +fn test_wait_timeout() { + let temp_dir = TempDir::new().unwrap(); + + // Start a long-running process + let mut cmd = Command::cargo_bin("demon").unwrap(); + cmd.current_dir(temp_dir.path()) + .args(&["run", "long", "sleep", "10"]) + .assert() + .success(); + + // Wait with a very short timeout (should fail) + let mut cmd = Command::cargo_bin("demon").unwrap(); + cmd.current_dir(temp_dir.path()) + .args(&["wait", "long", "--timeout", "2"]) + .assert() + .failure() + .stderr(predicate::str::contains("Timeout reached")); + + // Clean up the still-running process + let mut cmd = Command::cargo_bin("demon").unwrap(); + cmd.current_dir(temp_dir.path()) + .args(&["stop", "long"]) + .assert() + .success(); +} + +#[test] +fn test_wait_infinite_timeout() { + let temp_dir = TempDir::new().unwrap(); + + // Start a short process that will finish quickly + let mut cmd = Command::cargo_bin("demon").unwrap(); + cmd.current_dir(temp_dir.path()) + .args(&["run", "quick", "sleep", "1"]) + .assert() + .success(); + + // Wait with infinite timeout (should succeed quickly) + let mut cmd = Command::cargo_bin("demon").unwrap(); + cmd.current_dir(temp_dir.path()) + .args(&["wait", "quick", "--timeout", "0"]) + .assert() + .success(); +} + +#[test] +fn test_wait_custom_interval() { + let temp_dir = TempDir::new().unwrap(); + + // Start a short process + let mut cmd = Command::cargo_bin("demon").unwrap(); + cmd.current_dir(temp_dir.path()) + .args(&["run", "interval-test", "sleep", "2"]) + .assert() + .success(); + + // Wait with custom interval (should still succeed) + let mut cmd = Command::cargo_bin("demon").unwrap(); + cmd.current_dir(temp_dir.path()) + .args(&["wait", "interval-test", "--timeout", "5", "--interval", "2"]) + .assert() + .success(); +} -- cgit