From 0e95f6731849150a0396ec0fe119be7b4db95c76 Mon Sep 17 00:00:00 2001 From: Frank Bria Date: Tue, 20 Jan 2026 18:40:21 -0700 Subject: [PATCH] Add timeout command support for macOS (#108) * feat(timeout): add cross-platform timeout support for macOS Add portable timeout wrapper that automatically detects and uses the appropriate timeout command based on the platform: - Linux: Uses standard GNU `timeout` from coreutils - macOS: Uses `gtimeout` from Homebrew coreutils Changes: - Add lib/timeout_utils.sh with detect_timeout_command() and portable_timeout() functions - Update ralph_loop.sh to source timeout_utils.sh and use portable_timeout for Claude Code execution - Update install.sh to check for coreutils on macOS and provide installation instructions - Update test mocks to include gtimeout and portable_timeout - Update README.md with macOS coreutils installation instructions - Update CLAUDE.md with timeout_utils.sh documentation Users on macOS now need to install coreutils: brew install coreutils * Update model reference in opencode-review workflow * Update model name in opencode-review workflow * Update model version in opencode-review workflow * Update model version in opencode-review workflow * Update lib/timeout_utils.sh Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com> * Update opencode-review.yml --------- Co-authored-by: Claude Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com> --- CLAUDE.md | 7 ++ README.md | 18 +++++ install.sh | 46 ++++++++++--- lib/timeout_utils.sh | 145 +++++++++++++++++++++++++++++++++++++++ ralph_loop.sh | 5 +- tests/helpers/mocks.bash | 30 +++++++- 6 files changed, 237 insertions(+), 14 deletions(-) create mode 100755 lib/timeout_utils.sh diff --git a/CLAUDE.md b/CLAUDE.md index 6f88a03..6f4e46f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,13 @@ The system uses a modular architecture with reusable components in the `lib/` di - ISO timestamp generation for logging - Epoch time calculations for rate limiting +4. **lib/timeout_utils.sh** - Cross-platform timeout command utilities + - Detects and uses appropriate timeout command for the platform + - Linux: Uses standard `timeout` from GNU coreutils + - macOS: Uses `gtimeout` from Homebrew coreutils + - `portable_timeout()` function for seamless cross-platform execution + - Automatic detection with caching for performance + ## Key Commands ### Installation diff --git a/README.md b/README.md index 11aa1a4..b2bac75 100644 --- a/README.md +++ b/README.md @@ -471,6 +471,9 @@ my-project/ - **tmux** - Terminal multiplexer for integrated monitoring (recommended) - **jq** - JSON processing for status tracking - **Git** - Version control (projects are initialized as git repos) +- **GNU coreutils** - For the `timeout` command (execution timeouts) + - Linux: Pre-installed on most distributions + - macOS: Install via `brew install coreutils` (provides `gtimeout`) - **Standard Unix tools** - grep, date, etc. ### Testing Requirements (Development) @@ -524,6 +527,20 @@ brew install tmux sudo yum install tmux ``` +### Installing GNU coreutils (macOS) + +Ralph uses the `timeout` command for execution timeouts. On macOS, you need to install GNU coreutils: + +```bash +# Install coreutils (provides gtimeout) +brew install coreutils + +# Verify installation +gtimeout --version +``` + +Ralph automatically detects and uses `gtimeout` on macOS. No additional configuration is required after installation. + ## Monitoring and Debugging ### Live Dashboard @@ -569,6 +586,7 @@ tail -f logs/ralph.log - **Missing Dependencies** - Ensure Claude Code CLI and tmux are installed - **tmux Session Lost** - Use `tmux list-sessions` and `tmux attach` to reconnect - **Session Expired** - Sessions expire after 24 hours by default; use `--reset-session` to start fresh +- **timeout: command not found (macOS)** - Install GNU coreutils: `brew install coreutils` ## Contributing diff --git a/install.sh b/install.sh index 459930f..516bd91 100755 --- a/install.sh +++ b/install.sh @@ -33,38 +33,62 @@ log() { # Check dependencies check_dependencies() { log "INFO" "Checking dependencies..." - + local missing_deps=() - + local os_type + os_type=$(uname) + if ! command -v node &> /dev/null && ! command -v npx &> /dev/null; then missing_deps+=("Node.js/npm") fi - + if ! command -v jq &> /dev/null; then missing_deps+=("jq") fi - + if ! command -v git &> /dev/null; then missing_deps+=("git") fi - + + # Check for timeout command (platform-specific) + if [[ "$os_type" == "Darwin" ]]; then + # macOS: check for gtimeout from coreutils + if ! command -v gtimeout &> /dev/null && ! command -v timeout &> /dev/null; then + missing_deps+=("coreutils (for timeout command)") + fi + else + # Linux: check for standard timeout command + if ! command -v timeout &> /dev/null; then + missing_deps+=("coreutils") + fi + fi + if [ ${#missing_deps[@]} -ne 0 ]; then log "ERROR" "Missing required dependencies: ${missing_deps[*]}" echo "Please install the missing dependencies:" - echo " Ubuntu/Debian: sudo apt-get install nodejs npm jq git" - echo " macOS: brew install node jq git" - echo " CentOS/RHEL: sudo yum install nodejs npm jq git" + echo " Ubuntu/Debian: sudo apt-get install nodejs npm jq git coreutils" + echo " macOS: brew install node jq git coreutils" + echo " CentOS/RHEL: sudo yum install nodejs npm jq git coreutils" exit 1 fi - + + # Additional macOS-specific warning for coreutils + if [[ "$os_type" == "Darwin" ]]; then + if command -v gtimeout &> /dev/null; then + log "INFO" "GNU coreutils detected (gtimeout available)" + elif command -v timeout &> /dev/null; then + log "INFO" "timeout command available" + fi + fi + # Claude Code CLI will be downloaded automatically when first used log "INFO" "Claude Code CLI (@anthropic-ai/claude-code) will be downloaded when first used." - + # Check tmux (optional) if ! command -v tmux &> /dev/null; then log "WARN" "tmux not found. Install for integrated monitoring: apt-get install tmux / brew install tmux" fi - + log "SUCCESS" "Dependencies check completed" } diff --git a/lib/timeout_utils.sh b/lib/timeout_utils.sh new file mode 100755 index 0000000..4219928 --- /dev/null +++ b/lib/timeout_utils.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash + +# timeout_utils.sh - Cross-platform timeout utility functions +# Provides consistent timeout command execution across GNU (Linux) and BSD (macOS) systems +# +# On Linux: Uses the built-in GNU `timeout` command from coreutils +# On macOS: Uses `gtimeout` from Homebrew coreutils, or falls back to `timeout` if available + +# Cached timeout command to avoid repeated detection +export _TIMEOUT_CMD="" + +# Detect the available timeout command for this platform +# Sets _TIMEOUT_CMD to the appropriate command +# Returns 0 if a timeout command is available, 1 if not +detect_timeout_command() { + # Return cached result if already detected + if [[ -n "$_TIMEOUT_CMD" ]]; then + echo "$_TIMEOUT_CMD" + return 0 + fi + + local os_type + os_type=$(uname) + + if [[ "$os_type" == "Darwin" ]]; then + # macOS: Check for gtimeout (from Homebrew coreutils) first + if command -v gtimeout &> /dev/null; then + _TIMEOUT_CMD="gtimeout" + elif command -v timeout &> /dev/null; then + # Some macOS setups might have timeout available (e.g., MacPorts) + _TIMEOUT_CMD="timeout" + else + # No timeout command available + _TIMEOUT_CMD="" + return 1 + fi + else + # Linux and other Unix systems: use standard timeout + if command -v timeout &> /dev/null; then + _TIMEOUT_CMD="timeout" + else + # Timeout not found (unusual on Linux) + _TIMEOUT_CMD="" + return 1 + fi + fi + + echo "$_TIMEOUT_CMD" + return 0 +} + +# Check if a timeout command is available on this system +# Returns 0 if available, 1 if not +has_timeout_command() { + local cmd + cmd=$(detect_timeout_command 2>/dev/null) + [[ -n "$cmd" ]] +} + +# Get a user-friendly message about timeout availability +# Useful for error messages and installation instructions +get_timeout_status_message() { + local os_type + os_type=$(uname) + + if has_timeout_command; then + local cmd + cmd=$(detect_timeout_command) + echo "Timeout command available: $cmd" + return 0 + fi + + if [[ "$os_type" == "Darwin" ]]; then + echo "Timeout command not found. Install GNU coreutils: brew install coreutils" + else + echo "Timeout command not found. Install coreutils: sudo apt-get install coreutils" + fi + return 1 +} + +# Execute a command with a timeout (cross-platform) +# Usage: portable_timeout DURATION COMMAND [ARGS...] +# +# Arguments: +# DURATION - Timeout duration (e.g., "30s", "5m", "1h") +# COMMAND - The command to execute +# ARGS - Additional arguments for the command +# +# Returns: +# 0 - Command completed successfully within timeout +# 124 - Command timed out (GNU timeout behavior) +# 1 - No timeout command available (logs error) +# * - Exit code from the executed command +# +# Example: +# portable_timeout 30s curl -s https://example.com +# portable_timeout 5m npm install +# +portable_timeout() { + local duration=$1 + shift + + # Validate arguments + if [[ -z "$duration" ]]; then + echo "Error: portable_timeout requires a duration argument" >&2 + return 1 + fi + + if [[ $# -eq 0 ]]; then + echo "Error: portable_timeout requires a command to execute" >&2 + return 1 + fi + + # Detect the timeout command + local timeout_cmd + timeout_cmd=$(detect_timeout_command 2>/dev/null) + + if [[ -z "$timeout_cmd" ]]; then + local os_type + os_type=$(uname) + + echo "Error: No timeout command available on this system" >&2 + if [[ "$os_type" == "Darwin" ]]; then + echo "Install GNU coreutils on macOS: brew install coreutils" >&2 + else + echo "Install coreutils: sudo apt-get install coreutils" >&2 + fi + return 1 + fi + + # Execute the command with timeout + "$timeout_cmd" "$duration" "$@" +} + +# Reset the cached timeout command (useful for testing) +reset_timeout_detection() { + _TIMEOUT_CMD="" +} + +# Export functions for use in other scripts +export -f detect_timeout_command +export -f has_timeout_command +export -f get_timeout_status_message +export -f portable_timeout +export -f reset_timeout_detection diff --git a/ralph_loop.sh b/ralph_loop.sh index ccd039c..197651e 100755 --- a/ralph_loop.sh +++ b/ralph_loop.sh @@ -8,6 +8,7 @@ set -e # Exit on any error # Source library components SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")" source "$SCRIPT_DIR/lib/date_utils.sh" +source "$SCRIPT_DIR/lib/timeout_utils.sh" source "$SCRIPT_DIR/lib/response_analyzer.sh" source "$SCRIPT_DIR/lib/circuit_breaker.sh" @@ -866,7 +867,7 @@ execute_claude_code() { if [[ "$use_modern_cli" == "true" ]]; then # Modern execution with command array (shell-injection safe) # Execute array directly without bash -c to prevent shell metacharacter interpretation - if timeout ${timeout_seconds}s "${CLAUDE_CMD_ARGS[@]}" > "$output_file" 2>&1 & + if portable_timeout ${timeout_seconds}s "${CLAUDE_CMD_ARGS[@]}" > "$output_file" 2>&1 & then : # Continue to wait loop else @@ -879,7 +880,7 @@ execute_claude_code() { # Fall back to legacy stdin piping if modern mode failed or not enabled if [[ "$use_modern_cli" == "false" ]]; then - if timeout ${timeout_seconds}s $CLAUDE_CODE_CMD < "$PROMPT_FILE" > "$output_file" 2>&1 & + if portable_timeout ${timeout_seconds}s $CLAUDE_CODE_CMD < "$PROMPT_FILE" > "$output_file" 2>&1 & then : # Continue to wait loop else diff --git a/tests/helpers/mocks.bash b/tests/helpers/mocks.bash index d92ac33..99a9924 100644 --- a/tests/helpers/mocks.bash +++ b/tests/helpers/mocks.bash @@ -206,7 +206,7 @@ mock_stat() { return 0 } -# Mock timeout command +# Mock timeout command (Linux) mock_timeout() { local duration=$1 shift @@ -216,6 +216,28 @@ mock_timeout() { return $? } +# Mock gtimeout command (macOS coreutils) +# Same behavior as timeout - both are GNU coreutils timeout commands +mock_gtimeout() { + local duration=$1 + shift + + # Execute the command without actual timeout + "$@" + return $? +} + +# Mock portable_timeout (cross-platform wrapper from timeout_utils.sh) +# This mock bypasses the actual timeout detection and just executes the command +mock_portable_timeout() { + local duration=$1 + shift + + # Execute the command without actual timeout + "$@" + return $? +} + # Setup all mocks setup_mocks() { # Replace system commands with mocks @@ -225,6 +247,8 @@ setup_mocks() { function notify-send() { mock_notify_send "$@"; } function osascript() { mock_osascript "$@"; } function timeout() { mock_timeout "$@"; } + function gtimeout() { mock_gtimeout "$@"; } + function portable_timeout() { mock_portable_timeout "$@"; } export -f claude export -f tmux @@ -232,6 +256,8 @@ setup_mocks() { export -f notify-send export -f osascript export -f timeout + export -f gtimeout + export -f portable_timeout } # Teardown all mocks @@ -242,6 +268,8 @@ teardown_mocks() { unset -f notify-send unset -f osascript unset -f timeout + unset -f gtimeout + unset -f portable_timeout } # Set mock behavior