diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d214ba..b707a2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -# CI workflow for darwin-timeout. +# CI workflow for procguard. # # Fast feedback on every push/PR: lint first (fast fail), then test. # See CONTRIBUTING.md "CI Auto-Verification Rules" for the full test matrix. @@ -17,7 +17,7 @@ on: push: branches: [main, dev] tags-ignore: - - 'v*' + - "v*" pull_request: # run on all PRs, not just main @@ -85,12 +85,15 @@ jobs: - name: Verify binary run: | cargo build --release + # create timeout symlink for testing + ln -sf procguard target/release/timeout + ./target/release/procguard --version ./target/release/timeout --version - ./target/release/timeout 0.1s sleep 60 || true + ./target/release/procguard 0.1s sleep 60 || true - name: Verify binary size run: | - SIZE=$(stat -f%z target/release/timeout) + SIZE=$(stat -f%z target/release/procguard) echo "Binary size: $SIZE bytes ($(($SIZE / 1024))KB)" if [ $SIZE -gt 150000 ]; then echo "ERROR: Binary too large (>150KB)" @@ -99,7 +102,7 @@ jobs: - name: Verify symbol count run: | - SYMBOLS=$(nm -U target/release/timeout 2>/dev/null | wc -l) + SYMBOLS=$(nm -U target/release/procguard 2>/dev/null | wc -l) echo "Exported symbols: $SYMBOLS" if [ $SYMBOLS -gt 100 ]; then echo "ERROR: Too many symbols (>100), check strip settings" diff --git a/.github/workflows/homebrew.yml b/.github/workflows/homebrew.yml index ea3aabf..e445890 100644 --- a/.github/workflows/homebrew.yml +++ b/.github/workflows/homebrew.yml @@ -14,7 +14,7 @@ jobs: - name: Get SHA256 of universal binary id: sha run: | - URL="https://github.com/denispol/darwin-timeout/releases/download/${{ github.ref_name }}/timeout-macos-universal.tar.gz" + URL="https://github.com/denispol/procguard/releases/download/${{ github.ref_name }}/procguard-macos-universal.tar.gz" SHA=$(curl -sL "$URL" | shasum -a 256 | cut -d' ' -f1) echo "sha256=$SHA" >> $GITHUB_OUTPUT echo "SHA256: $SHA" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 20a6c52..8e54412 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -# Release workflow for darwin-timeout. +# Release workflow for procguard. # # Triggered when a version tag (v*) is pushed. Builds binaries for all # platforms and creates a GitHub release. @@ -8,9 +8,9 @@ # have passed CI. # # Produces: -# - timeout-macos-arm64.tar.gz (Apple Silicon) -# - timeout-macos-x86_64.tar.gz (Intel) -# - timeout-macos-universal.tar.gz (Universal binary, used by Homebrew) +# - procguard-macos-arm64.tar.gz (Apple Silicon) +# - procguard-macos-x86_64.tar.gz (Intel) +# - procguard-macos-universal.tar.gz (Universal binary, used by Homebrew) # # Usage: # git tag -a v1.0.0 -m "v1.0.0" @@ -21,7 +21,7 @@ name: Release on: push: tags: - - 'v*' + - "v*" env: CARGO_TERM_COLOR: always @@ -54,44 +54,48 @@ jobs: - name: Create binaries run: | mkdir -p dist staging - + # ARM64 (Apple Silicon) - cp target/aarch64-apple-darwin/release/timeout staging/timeout - strip staging/timeout - codesign -s - staging/timeout + cp target/aarch64-apple-darwin/release/procguard staging/procguard + strip staging/procguard + codesign -s - staging/procguard + ln -s procguard staging/timeout cp -r completions staging/ - tar -C staging -czvf dist/timeout-macos-arm64.tar.gz timeout completions - rm staging/timeout - + tar -C staging -czvf dist/procguard-macos-arm64.tar.gz procguard timeout completions + rm staging/procguard staging/timeout + # x86_64 (Intel) - cp target/x86_64-apple-darwin/release/timeout staging/timeout - strip staging/timeout - codesign -s - staging/timeout - tar -C staging -czvf dist/timeout-macos-x86_64.tar.gz timeout completions - rm staging/timeout - + cp target/x86_64-apple-darwin/release/procguard staging/procguard + strip staging/procguard + codesign -s - staging/procguard + ln -s procguard staging/timeout + tar -C staging -czvf dist/procguard-macos-x86_64.tar.gz procguard timeout completions + rm staging/procguard staging/timeout + # Universal (unversioned name for stable Homebrew URL) lipo -create \ - target/aarch64-apple-darwin/release/timeout \ - target/x86_64-apple-darwin/release/timeout \ - -output staging/timeout - strip staging/timeout - codesign -s - staging/timeout - tar -C staging -czvf dist/timeout-macos-universal.tar.gz timeout completions - rm staging/timeout - + target/aarch64-apple-darwin/release/procguard \ + target/x86_64-apple-darwin/release/procguard \ + -output staging/procguard + strip staging/procguard + codesign -s - staging/procguard + ln -s procguard staging/timeout + tar -C staging -czvf dist/procguard-macos-universal.tar.gz procguard timeout completions + rm staging/procguard staging/timeout + # Show results ls -lh dist/ - name: Verify binaries run: | - for f in dist/timeout-macos-*.tar.gz; do + for f in dist/procguard-macos-*.tar.gz; do echo "=== $f ===" tar -tzf "$f" tar -xzf "$f" -C /tmp - file /tmp/timeout + file /tmp/procguard + /tmp/procguard --version /tmp/timeout --version - rm -rf /tmp/timeout /tmp/completions + rm -rf /tmp/procguard /tmp/timeout /tmp/completions done - name: Create Release @@ -106,17 +110,18 @@ jobs: | Platform | File | |----------|------| - | Apple Silicon | `timeout-macos-arm64.tar.gz` | - | Intel | `timeout-macos-x86_64.tar.gz` | - | Universal | `timeout-macos-universal.tar.gz` | + | Apple Silicon | `procguard-macos-arm64.tar.gz` | + | Intel | `procguard-macos-x86_64.tar.gz` | + | Universal | `procguard-macos-universal.tar.gz` | ```bash # Example: Install universal binary - tar -xzf timeout-macos-universal.tar.gz - sudo mv timeout /usr/local/bin/ + tar -xzf procguard-macos-universal.tar.gz + sudo mv procguard timeout /usr/local/bin/ ``` - Verify with `timeout --version`. + Both `procguard` and `timeout` commands are included. + Verify with `procguard --version`. files: dist/*.tar.gz draft: false prerelease: false diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7bdfa97..ed67563 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,15 +1,15 @@ -# Contributing to darwin-timeout +# Contributing to procguard Thank you for your interest in contributing! This guide covers the development workflow and verification requirements. -darwin-timeout is both a **CLI tool** and a **Rust library**. The same codebase powers both—`src/main.rs` is the CLI entry point, `src/lib.rs` exposes the public library API. +procguard is both a **CLI tool** and a **Rust library**. The same codebase powers both—`src/main.rs` is the CLI entry point, `src/lib.rs` exposes the public library API. ## Quick Start ```bash # clone and build -git clone https://github.com/denispol/darwin-timeout.git -cd darwin-timeout +git clone https://github.com/denispol/procguard.git +cd procguard cargo build --release # run all tests (unit, integration, library API) @@ -85,7 +85,7 @@ CI automatically triggers extra verification based on which files you change. ** - `cargo fmt --check` - `cargo clippy -- -D warnings` - `cargo test --lib` (154 unit tests) -- `cargo test --test integration` (179 tests) +- `cargo test --test integration` (185 tests) - `cargo test --test library_api` (10 tests) - `cargo test --test proptest` (30 properties) - Binary size check (≤150KB) @@ -225,7 +225,7 @@ src/ └── allocator.rs # thin libc malloc wrapper tests/ -├── integration.rs # CLI integration tests (179 tests) +├── integration.rs # CLI integration tests (185 tests) ├── library_api.rs # library API tests (10 tests) ├── proptest.rs # property-based tests (30 properties) └── benchmarks.rs # performance benchmarks diff --git a/Cargo.lock b/Cargo.lock index f8b9bf6..4e91f4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,16 +76,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "darwin-timeout" -version = "1.4.0" -dependencies = [ - "assert_cmd", - "libc", - "predicates", - "proptest", -] - [[package]] name = "difflib" version = "0.4.0" @@ -222,6 +212,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "procguard" +version = "1.5.0" +dependencies = [ + "assert_cmd", + "libc", + "predicates", + "proptest", +] + [[package]] name = "proptest" version = "1.9.0" diff --git a/Cargo.toml b/Cargo.toml index d935ee1..8ec4dfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,15 @@ [package] -name = "darwin-timeout" -version = "1.4.0" +name = "procguard" +version = "1.5.0" edition = "2024" rust-version = "1.91" authors = ["denispol"] -description = "Run a command with a time limit (GNU timeout clone for Darwin/Apple platforms)" +description = "The formally verified process supervisor for macOS. Enforces time limits, memory quotas, and CPU throttling." license = "MIT" -repository = "https://github.com/denispol/darwin-timeout" +repository = "https://github.com/denispol/procguard" readme = "README.md" -documentation = "https://docs.rs/darwin-timeout" -keywords = ["cli", "timeout", "process", "macos", "darwin"] +documentation = "https://docs.rs/procguard" +keywords = ["cli", "timeout", "process", "macos", "supervisor"] categories = ["command-line-utilities", "os::macos-apis"] # publish allowlist - keep crates.io package small and deterministic @@ -25,7 +25,7 @@ include = [ ] [lib] -name = "darwin_timeout" +name = "procguard" path = "src/lib.rs" [dependencies] @@ -67,6 +67,14 @@ opt-level = 3 debug-assertions = true overflow-checks = true +# Both binaries share the same source - behavior detected via argv[0] at runtime: +# - "procguard": defaults to --confine wall (sleep-aware) +# - "timeout": defaults to --confine active (GNU-compatible) +# Note: Cargo warns about shared main.rs; this is intentional for cargo install support. +[[bin]] +name = "procguard" +path = "src/main.rs" + [[bin]] name = "timeout" path = "src/main.rs" diff --git a/README.md b/README.md index 9161a46..1d808c2 100644 --- a/README.md +++ b/README.md @@ -1,430 +1,133 @@ -darwin-timeout -============== +# procguard -GNU `timeout` for macOS, done right. Works through sleep. ~100KB. Zero dependencies. +Kill runaway processes before they freeze your Mac. -**CLI tool** — drop-in replacement for GNU timeout. -**Rust library** — embed timeout logic in your own tools. - - brew install denispol/tap/darwin-timeout # CLI - cargo add darwin-timeout # library - -Works exactly like GNU timeout: - - timeout 30s ./slow-command # kill after 30 seconds - timeout -k 5 1m ./stubborn # SIGTERM, then SIGKILL after 5s - timeout --preserve-status 10s ./cmd # exit with command's status - -Plus features GNU doesn't have: - - timeout --json 5m ./test-suite # JSON output for CI - timeout -c active 1h ./benchmark # pause timer during sleep (GNU behavior) - timeout --on-timeout 'cleanup.sh' 30s ./task # pre-timeout hook - timeout --retry 3 30s ./flaky-test # retry on timeout - timeout --mem-limit 1G 1h ./build # kill if memory exceeds 1GB - timeout --cpu-percent 50 1h ./batch # throttle to 50% CPU - -**Coming from GNU coreutils?** darwin-timeout defaults to wall-clock time (survives sleep). Use `-c active` for GNU-like behavior where the timer pauses during sleep. - -Why? ----- - -Apple doesn't ship `timeout`. The alternatives have problems: - -**GNU coreutils** (`brew install coreutils`): - -- 15.7MB and 475 files for one command -- **Stops counting when your Mac sleeps** (uses `nanosleep`) - -**uutils** (Rust rewrite of coreutils): - -- Also stops counting during sleep (uses `Instant`/`mach_absolute_time`) - -darwin-timeout uses `mach_continuous_time`, the only macOS clock that keeps ticking through sleep. Set 1 hour, get 1 hour, even if you close your laptop. - -**Scenario:** `timeout 1h ./build` with laptop sleeping 45min in the middle - - 0 15min 1h 1h 45min - ├──────────┬──────────────────────────────┬──────────────────────────────┤ - Real time │▓▓▓▓▓▓▓▓▓▓│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│ - │ awake │ sleep │ awake │ - └──────────┴──────────────────────────────┴──────────────────────────────┘ - - darwin-timeout |██████████|██████████████████████████████^ fires at 1h ✓ - (counts sleep time) - - GNU timeout |██████████|······························|██████████████████████████████^ fires at 1h 45min ✗ - (pauses during sleep) - - Legend: ▓ awake ░ sleep █ counting · paused ^ fire point - -| | darwin-timeout | GNU coreutils | -|---------------------------|----------------|---------------| -| Works during system sleep | ✓ | ✗ | -| Selectable time mode | ✓ (wall/active)| ✗ (active only)| -| **Resource limits** | ✓ (mem/CPU) | ✗ | -| JSON output | ✓ | ✗ | -| Retry on timeout | ✓ | ✗ | -| Stdin idle timeout | ✓ | ✗ | -| Pre-timeout hooks | ✓ | ✗ | -| CI heartbeat (keep-alive) | ✓ | ✗ | -| Wait-for-file | ✓ | ✗ | -| Custom exit codes | ✓ | ✗ | -| Env var configuration | ✓ | ✗ | -| Binary size | ~100KB | 15.7MB | -| Startup time | 3.6ms | 4.2ms | -| Zero CPU while waiting | ✓ (kqueue) | ✓ (nanosleep) | - -*Performance data from [250 benchmark runs](#benchmarks) on Apple M4 Pro.* - -100% GNU-compatible. All flags work identically (`-s`, `-k`, `-p`, `-f`, `-v`). Drop-in replacement for Apple Silicon and Intel Macs. - -Quality & Testing ------------------ - -darwin-timeout uses a **five-layer verification approach**: - -| Method | Coverage | What It Catches | -|--------|----------|-----------------| -| **Unit tests** | 154 tests | Logic errors, edge cases | -| **Integration tests** | 179 tests | Real process behavior, signals, I/O | -| **Library API tests** | 10 tests | Public API usability, lifecycle | -| **Property-based (proptest)** | 30 properties, ~7500 cases | Input invariants, mathematical relationships | -| **Fuzzing (cargo-fuzz)** | 4 targets, ~70M executions | Crashes, panics, hangs from malformed input | -| **Formal verification (kani)** | 19 proofs | Mathematical proof of memory safety, no overflows | - -**What this means for you:** -- Parsing code is fuzz-tested (found and fixed bugs before release) -- Unsafe code has formal proofs (mathematically verified, not just tested) -- State machines are proven correct (no race conditions in signal handling) -- Arithmetic is overflow-checked (all time calculations verified) - -See [docs/VERIFICATION.md](docs/VERIFICATION.md) for methodology details. - -Install -------- - -**Homebrew** (recommended): - - brew install denispol/tap/darwin-timeout - -**Binary download:** Grab the universal binary (arm64 + x86_64) from [releases](https://github.com/denispol/darwin-timeout/releases). - -**From source (CLI):** - - cargo build --release - sudo cp target/release/timeout /usr/local/bin/ - -**As a Rust library:** - - cargo add darwin-timeout - -Shell completions are installed automatically with Homebrew. For manual install, see [completions/](completions/). - -Quick Start ------------ - - timeout 30 ./slow-command # kill after 30 seconds - timeout -k 5 30 ./stubborn # SIGTERM, then SIGKILL after 5s - timeout --json 1m ./build # JSON output for CI - timeout -v 10 ./script # verbose: shows signals sent - -Use Cases ---------- - -**CI/CD**: Stop flaky tests before they hang your pipeline. - - timeout --json 5m ./run-tests - -**Overnight builds**: Timeouts that work even when your Mac sleeps. - - timeout 2h make release # 2 hours wall-clock, guaranteed - -**Network ops**: Don't wait forever for unresponsive servers. - - timeout 10s curl https://api.example.com/health - -**Script safety**: Ensure cleanup scripts actually finish. - - timeout -k 10s 60s ./cleanup.sh - -**Coordinated startup**: Wait for dependencies before running. - - timeout --wait-for-file /tmp/db-ready 5m ./migrate - -**Prompt detection**: Kill commands that unexpectedly prompt for input. Catch interactive tests hanging on stdin in unattended CI environments. - - timeout --stdin-timeout 5s ./test-suite # fail if it prompts for input - -> **Note:** `--stdin-timeout` alone **consumes stdin data** to detect activity—the child won't receive it. This is ideal for detecting unexpected prompts in non-interactive CI. - -**Stream watchdog**: Detect stalled data pipelines without consuming the stream. If upstream stops sending data for too long, kill the pipeline. The child receives all data intact, which is perfect for production backup and log shipping pipelines. - - # Database backup: kill if pg_dump stalls for 2+ minutes - # Protects against database locks, network issues, or hung queries - pg_dump mydb | timeout -S 2m --stdin-passthrough 4h gzip | \ - aws s3 cp - s3://backups/db-$(date +%Y%m%d).sql.gz - - # Kubernetes log shipping: fail if pod stops emitting logs for 30s - # Catches crashed pods, network issues, or stuck log tails - kubectl logs -f deployment/app --since=10m | \ - timeout -S 30s --stdin-passthrough 24h ./ship-to-elasticsearch.sh - - # Real-time data sync: abort if upstream stops sending for 5 minutes - nc data-source 9000 | timeout -S 5m --stdin-passthrough 48h ./process-stream.sh - -> **How it works:** The timer resets on every stdin activity. When stdin reaches EOF (closed pipe), monitoring stops and wall clock timeout takes over. Data flows to the child untouched. - -**CI keep-alive**: Many CI systems (GitHub Actions, GitLab CI, Travis) kill jobs that produce no output for 10-30 minutes. Long builds, test suites, or deployments can trigger this even when working correctly. The heartbeat flag prints periodic status messages to prevent these false timeouts: - - timeout --heartbeat 60s 2h ./integration-tests - # every 60s: timeout: heartbeat: 5m 0s elapsed, command still running (pid 12345) - - # combine with --json for structured CI output - timeout --heartbeat 30s --json 1h ./deploy.sh - -**Resource sandboxing**: Enforce memory and CPU limits on untrusted or runaway processes. No containers, no cgroups, no root—just process-level enforcement that actually works on macOS. - - # Memory guard: kill if build exceeds 4GB (prevents OOM-induced system freeze) - timeout --mem-limit 4G 2h make -j8 - - # CPU throttle: limit background job to 50% of one core - timeout --cpu-percent 50 1h ./batch-process - - # Multi-core cap: allow up to 4 cores (400%) for parallel builds - timeout --cpu-percent 400 --mem-limit 8G 1h cargo build --release -j8 - - # Full resource box: time + memory + CPU limits together - timeout --mem-limit 512M --cpu-percent 25 --cpu-time 5m 30m ./untrusted-script - -> **Why this matters:** macOS has no cgroups. `ulimit` memory limits don't work. Until now, there was no way to enforce resource limits on a single command without containers or third-party daemons. darwin-timeout brings Linux-style resource control to macOS, in ~100KB. - -Options -------- - - timeout [OPTIONS] DURATION COMMAND [ARGS...] - -**GNU-compatible flags:** - - -s, --signal SIG signal to send (default: TERM) - -k, --kill-after T send SIGKILL if still running after T - -v, --verbose print signals to stderr - -p, --preserve-status exit with command's status, not 124 - -f, --foreground don't create process group - -**darwin-timeout extensions:** - - -q, --quiet suppress error messages - -c, --confine MODE time mode: 'wall' (default) or 'active' - -H, --heartbeat T print status to stderr every T (for CI keep-alive) - --json JSON output for scripting - --on-timeout CMD run CMD on timeout (before kill); %p = child PID - --on-timeout-limit T time limit for --on-timeout (default: 5s) - --timeout-exit-code N exit with N instead of 124 on timeout - --wait-for-file PATH wait for file to exist before starting command - --wait-for-file-timeout T timeout for --wait-for-file (default: wait forever) - -r, --retry N retry command up to N times on timeout - --retry-delay T delay between retries (default: 0) - --retry-backoff Nx multiply delay by N each retry (e.g., 2x) - -S, --stdin-timeout T kill command if stdin is idle for T (consumes stdin; for prompt detection) - --stdin-passthrough non-consuming stdin idle detection (pair with -S) - --mem-limit SIZE kill if memory exceeds SIZE (e.g., 512M, 2G, 1T) - --cpu-time T hard CPU time limit via RLIMIT_CPU (e.g., 30s, 5m) - --cpu-percent PCT throttle CPU to PCT% via SIGSTOP/SIGCONT (e.g., 50, 200) - -**Duration format:** number with optional suffix `ms` (milliseconds), `us`/`µs` (microseconds), `s` (seconds), `m` (minutes), `h` (hours), `d` (days). Fractional values supported: `0.5s`, `1.5ms`, `100us`. - -**Exit codes:** - - 0 command completed successfully - 124 timed out, or --wait-for-file timed out (custom via --timeout-exit-code) - 125 timeout itself failed - 126 command found but not executable - 127 command not found - 128+N command killed by signal N - -Time Modes ----------- - -**wall** (default): Real elapsed time, including system sleep. A 1-hour timeout fires after 1 hour of wall-clock time, even if your Mac sleeps for 45 minutes. - - timeout 1h ./build - timeout -c wall 1h ./build # explicit - -**active**: Only counts time when the system is awake. This matches GNU timeout behavior, useful for benchmarks or when you want the timer to pause during sleep. - - timeout -c active 1h ./benchmark # pauses during sleep, like GNU timeout - -Under the hood: `wall` uses `mach_continuous_time`, `active` uses `CLOCK_MONOTONIC_RAW`. - -Resource Limits ---------------- - -Enforce memory and CPU constraints without containers or root privileges. Three complementary mechanisms: - -**Memory limit** (`--mem-limit`): Kill process if physical memory exceeds threshold. - - timeout --mem-limit 2G 1h ./memory-hungry-app - timeout --mem-limit 512M --kill-after 5s 30m ./leak-prone-service - -Supports: `K`/`KB`, `M`/`MB`, `G`/`GB`, `T`/`TB` (binary units, case-insensitive). - -**CPU time limit** (`--cpu-time`): Hard limit on total CPU seconds consumed. - - timeout --cpu-time 5m 1h ./compute-job # max 5 minutes of CPU time - -Kernel-enforced via RLIMIT_CPU. Process receives SIGXCPU then SIGKILL. - -**CPU throttle** (`--cpu-percent`): Actively limit CPU usage percentage. - - timeout --cpu-percent 50 1h ./background-task # 50% of one core - timeout --cpu-percent 200 1h ./parallel-job # max 2 cores - timeout --cpu-percent 800 1h make -j8 # max 8 cores - -Uses SIGSTOP/SIGCONT with integral control for precise convergence to target percentage. Multi-core aware: 100 = 1 core, 400 = 4 cores. - -**Combining limits**: All three can be used together. - - timeout --mem-limit 1G --cpu-time 10m --cpu-percent 50 1h ./untrusted - -See [docs/resource-limits.md](docs/resource-limits.md) for implementation details, algorithm explanation, and advanced use cases. - -JSON Output ------------ - -Machine-readable output for CI/CD pipelines and automation: - - $ timeout --json 1s sleep 0.5 - {"schema_version":7,"status":"completed","exit_code":0,"elapsed_ms":504,"user_time_ms":1,"system_time_ms":2,"max_rss_kb":1248} - - $ timeout --json 0.5s sleep 10 - {"schema_version":7,"status":"timeout","timeout_reason":"wall_clock","signal":"SIGTERM","signal_num":15,"killed":false,"command_exit_code":-1,"exit_code":124,"elapsed_ms":502,"user_time_ms":0,"system_time_ms":1,"max_rss_kb":1232} - -**Status types:** `completed`, `timeout`, `memory_limit`, `signal_forwarded`, `error` - -**Timeout reasons:** `wall_clock` (main timeout), `stdin_idle` (-S/--stdin-timeout) - -Includes resource usage metrics: CPU time (`user_time_ms`, `system_time_ms`) and peak memory (`max_rss_kb`). - -See [docs/json-output.md](docs/json-output.md) for complete schema documentation, field reference, and integration examples. - -Environment Variables ---------------------- - -Configure defaults without CLI flags: - - TIMEOUT default duration if CLI arg isn't a valid duration - TIMEOUT_SIGNAL default signal (overridden by -s) - TIMEOUT_KILL_AFTER default kill-after (overridden by -k) - TIMEOUT_RETRY default retry count (overridden by -r/--retry) - TIMEOUT_HEARTBEAT default heartbeat interval (overridden by -H/--heartbeat) - TIMEOUT_STDIN_TIMEOUT default stdin idle timeout (overridden by -S/--stdin-timeout) - TIMEOUT_WAIT_FOR_FILE default file to wait for - TIMEOUT_WAIT_FOR_FILE_TIMEOUT timeout for wait-for-file - -Pre-timeout Hooks ------------------ - -Run a command when timeout fires, before sending the signal: - - timeout --on-timeout 'echo "killing $p" >> /tmp/log' 5s ./long-task - timeout --on-timeout 'kill -QUIT %p' --on-timeout-limit 2s 30s ./server - -`%p` is replaced with the child PID. Hooks have their own timeout (default 5s). +```bash +procguard --mem-limit 4G 2h make -j8 +``` -How It Works ------------- +macOS has no native memory limits or timeout command. procguard adds both. -Built on Darwin kernel primitives: + brew install denispol/tap/procguard -- **kqueue + EVFILT_PROC + EVFILT_TIMER**: monitors process exit and timeout with zero CPU overhead -- **mach_continuous_time**: wall-clock that survives system sleep (the key differentiator) -- **CLOCK_MONOTONIC_RAW**: active-time clock, pauses during sleep -- **posix_spawn**: lightweight process creation (faster than fork+exec) -- **Signal forwarding**: SIGTERM/SIGINT/SIGHUP/SIGQUIT/SIGUSR1/SIGUSR2 forwarded to child process group -- **Process groups**: child runs in own group so signals reach all descendants +## What else? -~100KB `no_std` binary. Custom allocator, direct syscalls, no libstd runtime. +```bash +procguard --cpu-percent 50 1h ./batch # throttle CPU +procguard --cpu-time 5m 1h ./compute # cap CPU seconds +procguard --retry 3 --json 5m ./test # CI-friendly +procguard --heartbeat 60s 2h ./long-job # keep CI alive +procguard --wait-for-file /tmp/ready 5m ./app # wait for deps +procguard --on-timeout 'cleanup.sh' 30m ./job # hook before kill +``` -Benchmarks ----------- +## GNU-compatible `timeout` -All benchmarks on Apple M4 Pro, macOS Tahoe 26.2, hyperfine 1.20.0. -See [docs/benchmarks/](docs/benchmarks/) for raw data and methodology. +Apple doesn't ship one. procguard does: - # Binary size - darwin-timeout: ~100KB - GNU coreutils: 15.7MB (157x larger) +```bash +timeout 30s ./command # GNU-compatible +timeout -k 5 1m ./stubborn # all the flags work +``` - # Startup overhead (250 runs across 5 sessions) - darwin-timeout: 3.6ms ± 0.2ms - GNU timeout: 4.2ms ± 0.2ms (18% slower) +Same behavior, same exit codes. Your Linux scripts just work. - # Timeout precision (20 runs, 1s timeout) - darwin-timeout: 1.014s ± 0.003s - GNU timeout: 1.017s ± 0.001s (identical) +## Install - # CPU while waiting - darwin-timeout: 0.00 user, 0.00 sys (kqueue blocks) +```bash +brew install denispol/tap/procguard +# or +cargo install procguard +``` - # Feature overhead (vs baseline) - --json flag: 0% overhead - --verbose flag: 0% overhead - --retry flag: 0% overhead (when not triggered) - --heartbeat flag: 0% overhead (prints only at intervals) +Both install `procguard` and `timeout` binaries. -Development ------------ +## The Rust stuff - cargo test # run tests - cargo test --test proptest # property-based tests - cargo clippy # lint - ./scripts/verify-all.sh # full verification suite +`no_std`. ~100KB. 3.6ms startup. Zero dependencies beyond libc. -**Contributing:** See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow, verification requirements, and the testing pyramid. +Built on Darwin internals most people never touch: -**Verification:** This project uses five verification methods: unit tests, integration tests, proptest, cargo-fuzz, and kani formal proofs. See [docs/VERIFICATION.md](docs/VERIFICATION.md) for details. +- **kqueue** for zero-CPU event waiting +- **posix_spawn** instead of fork (matters on Apple Silicon) +- **proc_pid_rusage** for memory stats without entitlements +- **mach_continuous_time** - the only clock that survives sleep -Library Usage -------------- +19 [Kani](https://github.com/model-checking/kani) proofs verify critical invariants: mathematical proofs, not just tests. -The `darwin_timeout` crate exposes the core timeout functionality for embedding in your own tools: +### As a library ```rust -use darwin_timeout::{RunConfig, RunResult, Signal, run_command, setup_signal_forwarding}; +use procguard::{RunConfig, RunResult, run_command}; use std::time::Duration; -let _ = setup_signal_forwarding(); - let config = RunConfig { timeout: Duration::from_secs(30), - signal: Signal::SIGTERM, kill_after: Some(Duration::from_secs(5)), - ..RunConfig::default() + ..Default::default() }; let args = ["-c".to_string(), "sleep 10".to_string()]; match run_command("sh", &args, &config) { - Ok(RunResult::Completed { status, rusage }) => { - println!("Completed with exit code {:?}", status.code()); - } - Ok(RunResult::TimedOut { signal, .. }) => { - println!("Timed out, sent {:?}", signal); - } - Ok(_) => println!("Other outcome"), - Err(e) => eprintln!("Error: {}", e), + Ok(RunResult::TimedOut { .. }) => println!("timed out"), + Ok(RunResult::Completed { status, .. }) => println!("exit {}", status.code().unwrap_or(-1)), + _ => {} } ``` -**Platform:** macOS only (uses Darwin kernel APIs). + cargo add procguard -**API Docs:** [docs.rs/darwin-timeout](https://docs.rs/darwin-timeout) +## Reference -**Performance:** Library calls have the same performance as CLI invocations—both use identical code paths. No additional overhead. +``` +procguard [OPTIONS] DURATION COMMAND [ARGS...] + +Timeout: + -s, --signal SIG signal to send (default: TERM) + -k, --kill-after T SIGKILL if still running after T + -p, --preserve-status exit with command's status + -f, --foreground don't create process group + +Resources: + --mem-limit SIZE kill if memory exceeds (512M, 2G) + --cpu-time T CPU time limit (30s, 5m) + --cpu-percent PCT throttle to PCT% + +Lifecycle: + -r, --retry N retry N times on timeout + --retry-delay T delay between retries + --retry-backoff Nx exponential backoff (2x, 3x) + --wait-for-file PATH wait for file before starting + --wait-for-file-timeout T timeout for file wait + --on-timeout CMD run before killing (%p = PID) + --on-timeout-limit T timeout for hook (default: 5s) + +Input/Output: + -v, --verbose show signals sent + -q, --quiet suppress errors + --json machine-readable output + -H, --heartbeat T periodic status messages + -S, --stdin-timeout T kill if stdin idle for T + --stdin-passthrough non-consuming stdin detection + --timeout-exit-code N custom exit code on timeout + +Time: + -c, --confine MODE 'wall' (default) or 'active' +``` + +`wall` = real time including sleep. `active` = pauses during sleep (GNU-compatible). + +**Exit codes:** 0 ok, 124 timeout, 125 error, 126 not executable, 127 not found, 128+N signal + +## Development + +```bash +cargo test # 349 tests +./scripts/verify-all.sh # + fuzz + kani proofs +``` -> ⚠️ **Stability:** The library API is experimental. Use `..RunConfig::default()` when constructing configs and include wildcard arms in `RunResult` matches to ensure forward compatibility with new fields and variants. +[CONTRIBUTING.md](CONTRIBUTING.md) · [docs/VERIFICATION.md](docs/VERIFICATION.md) -License -------- +## License MIT diff --git a/build.rs b/build.rs index 31d711a..81ba0f9 100644 --- a/build.rs +++ b/build.rs @@ -1,7 +1,7 @@ /* * build.rs * - * Build script for darwin-timeout. + * Build script for procguard. * Ensures libc is linked for the no_std binary. */ diff --git a/completions/timeout.bash b/completions/procguard.bash similarity index 93% rename from completions/timeout.bash rename to completions/procguard.bash index 6d6d769..0f9e7a4 100644 --- a/completions/timeout.bash +++ b/completions/procguard.bash @@ -1,7 +1,8 @@ -# bash completion for darwin-timeout +# bash completion for procguard # copy to /etc/bash_completion.d/ or source in ~/.bashrc +# Note: Also works when invoked as 'timeout' alias -_timeout_completions() { +_procguard_completions() { local cur prev opts COMPREPLY=() cur="${COMP_WORDS[COMP_CWORD]}" @@ -91,4 +92,5 @@ _timeout_completions() { fi } -complete -F _timeout_completions timeout +complete -F _procguard_completions procguard +complete -F _procguard_completions timeout diff --git a/completions/procguard.fish b/completions/procguard.fish new file mode 100644 index 0000000..f63d496 --- /dev/null +++ b/completions/procguard.fish @@ -0,0 +1,61 @@ +# fish completion for procguard +# copy to ~/.config/fish/completions/procguard.fish +# Note: Also symlink to timeout.fish for the timeout alias + +# Disable file completion by default +complete -c procguard -f +complete -c timeout -f + +# Signals +set -l signals TERM HUP INT QUIT KILL USR1 USR2 ALRM STOP CONT + +# Duration suggestions +set -l durations 1s 5s 10s 30s 1m 5m 10m 1h + +# Options for procguard +complete -c procguard -s h -l help -d 'Show help message' +complete -c procguard -s V -l version -d 'Show version' +complete -c procguard -s s -l signal -d 'Signal to send on timeout' -xa "$signals" +complete -c procguard -s k -l kill-after -d 'Send KILL after duration' -xa "$durations" +complete -c procguard -s p -l preserve-status -d 'Exit with command status on timeout' +complete -c procguard -s f -l foreground -d 'Run in foreground (allow TTY access)' +complete -c procguard -s v -l verbose -d 'Diagnose signals to stderr' +complete -c procguard -s q -l quiet -d 'Suppress diagnostic output' +complete -c procguard -s c -l confine -d 'Time mode (wall or active)' -xa 'wall active' +complete -c procguard -l timeout-exit-code -d 'Exit code on timeout' -xa '124 125 0 1' +complete -c procguard -l on-timeout -d 'Command to run before signaling' -xa '(__fish_complete_command)' +complete -c procguard -l on-timeout-limit -d 'Timeout for hook command' -xa "$durations" +complete -c procguard -l wait-for-file -d 'Wait for file to exist before starting' -rF +complete -c procguard -l wait-for-file-timeout -d 'Timeout for wait-for-file' -xa "$durations" +complete -c procguard -s r -l retry -d 'Retry command N times on timeout' -xa '1 2 3 5 10' +complete -c procguard -l retry-delay -d 'Delay between retries' -xa "$durations" +complete -c procguard -l retry-backoff -d 'Multiply delay by N each retry' -xa '2x 3x 4x' +complete -c procguard -s H -l heartbeat -d 'Print status to stderr at interval' -xa "$durations" +complete -c procguard -s S -l stdin-timeout -d 'Kill if stdin idle for duration' -xa "$durations" +complete -c procguard -l json -d 'Output JSON for scripting' +complete -c procguard -n '__fish_is_first_arg' -xa "$durations" +complete -c procguard -n 'not __fish_is_first_arg' -xa '(__fish_complete_command)' + +# Same options for timeout alias +complete -c timeout -s h -l help -d 'Show help message' +complete -c timeout -s V -l version -d 'Show version' +complete -c timeout -s s -l signal -d 'Signal to send on timeout' -xa "$signals" +complete -c timeout -s k -l kill-after -d 'Send KILL after duration' -xa "$durations" +complete -c timeout -s p -l preserve-status -d 'Exit with command status on timeout' +complete -c timeout -s f -l foreground -d 'Run in foreground (allow TTY access)' +complete -c timeout -s v -l verbose -d 'Diagnose signals to stderr' +complete -c timeout -s q -l quiet -d 'Suppress diagnostic output' +complete -c timeout -s c -l confine -d 'Time mode (wall or active)' -xa 'wall active' +complete -c timeout -l timeout-exit-code -d 'Exit code on timeout' -xa '124 125 0 1' +complete -c timeout -l on-timeout -d 'Command to run before signaling' -xa '(__fish_complete_command)' +complete -c timeout -l on-timeout-limit -d 'Timeout for hook command' -xa "$durations" +complete -c timeout -l wait-for-file -d 'Wait for file to exist before starting' -rF +complete -c timeout -l wait-for-file-timeout -d 'Timeout for wait-for-file' -xa "$durations" +complete -c timeout -s r -l retry -d 'Retry command N times on timeout' -xa '1 2 3 5 10' +complete -c timeout -l retry-delay -d 'Delay between retries' -xa "$durations" +complete -c timeout -l retry-backoff -d 'Multiply delay by N each retry' -xa '2x 3x 4x' +complete -c timeout -s H -l heartbeat -d 'Print status to stderr at interval' -xa "$durations" +complete -c timeout -s S -l stdin-timeout -d 'Kill if stdin idle for duration' -xa "$durations" +complete -c timeout -l json -d 'Output JSON for scripting' +complete -c timeout -n '__fish_is_first_arg' -xa "$durations" +complete -c timeout -n 'not __fish_is_first_arg' -xa '(__fish_complete_command)' diff --git a/completions/timeout.zsh b/completions/procguard.zsh similarity index 94% rename from completions/timeout.zsh rename to completions/procguard.zsh index 924ac29..a008178 100644 --- a/completions/timeout.zsh +++ b/completions/procguard.zsh @@ -1,9 +1,10 @@ -#compdef timeout +#compdef procguard timeout -# zsh completion for darwin-timeout +# zsh completion for procguard # copy to a directory in your $fpath, e.g., ~/.zsh/completions/ +# Note: Works for both 'procguard' and 'timeout' alias -_timeout() { +_procguard() { local context state state_descr line typeset -A opt_args @@ -71,4 +72,4 @@ _timeout() { return 1 } -_timeout "$@" +_procguard "$@" diff --git a/completions/timeout.fish b/completions/timeout.fish deleted file mode 100644 index e52d2f3..0000000 --- a/completions/timeout.fish +++ /dev/null @@ -1,39 +0,0 @@ -# fish completion for darwin-timeout -# copy to ~/.config/fish/completions/timeout.fish - -# Disable file completion by default -complete -c timeout -f - -# Signals -set -l signals TERM HUP INT QUIT KILL USR1 USR2 ALRM STOP CONT - -# Duration suggestions -set -l durations 1s 5s 10s 30s 1m 5m 10m 1h - -# Options -complete -c timeout -s h -l help -d 'Show help message' -complete -c timeout -s V -l version -d 'Show version' -complete -c timeout -s s -l signal -d 'Signal to send on timeout' -xa "$signals" -complete -c timeout -s k -l kill-after -d 'Send KILL after duration' -xa "$durations" -complete -c timeout -s p -l preserve-status -d 'Exit with command status on timeout' -complete -c timeout -s f -l foreground -d 'Run in foreground (allow TTY access)' -complete -c timeout -s v -l verbose -d 'Diagnose signals to stderr' -complete -c timeout -s q -l quiet -d 'Suppress diagnostic output' -complete -c timeout -s c -l confine -d 'Time mode (wall or active)' -xa 'wall active' -complete -c timeout -l timeout-exit-code -d 'Exit code on timeout' -xa '124 125 0 1' -complete -c timeout -l on-timeout -d 'Command to run before signaling' -xa '(__fish_complete_command)' -complete -c timeout -l on-timeout-limit -d 'Timeout for hook command' -xa "$durations" -complete -c timeout -l wait-for-file -d 'Wait for file to exist before starting' -rF -complete -c timeout -l wait-for-file-timeout -d 'Timeout for wait-for-file' -xa "$durations" -complete -c timeout -s r -l retry -d 'Retry command N times on timeout' -xa '1 2 3 5 10' -complete -c timeout -l retry-delay -d 'Delay between retries' -xa "$durations" -complete -c timeout -l retry-backoff -d 'Multiply delay by N each retry' -xa '2x 3x 4x' -complete -c timeout -s H -l heartbeat -d 'Print status to stderr at interval' -xa "$durations" -complete -c timeout -s S -l stdin-timeout -d 'Kill if stdin idle for duration' -xa "$durations" -complete -c timeout -l json -d 'Output JSON for scripting' - -# First positional argument: duration -complete -c timeout -n '__fish_is_first_arg' -xa "$durations" - -# After duration: complete commands -complete -c timeout -n 'not __fish_is_first_arg' -xa '(__fish_complete_command)' diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index 6b09771..9c9f8a0 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -26,7 +26,7 @@ cargo kani ``` ┌─────────────────────────────────────────────────────────────────────────────┐ │ VERIFICATION PYRAMID │ -│ darwin-timeout │ +│ procguard │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ ▲ │ @@ -140,7 +140,7 @@ cargo test --test integration cargo test --test library_api ``` -**CLI Integration** (`tests/integration.rs` - 179 tests): +**CLI Integration** (`tests/integration.rs` - 185 tests): - timeout behavior (wall clock, active time) - signal forwarding (SIGTERM, SIGKILL, SIGINT) - process groups and cleanup @@ -297,7 +297,7 @@ fuzz/corpus/parse_duration/ /* fuzz/fuzz_targets/my_target.rs */ #![no_main] use libfuzzer_sys::fuzz_target; -use darwin_timeout::my_function; +use procguard::my_function; fuzz_target!(|data: &[u8]| { if let Ok(s) = std::str::from_utf8(data) { @@ -406,7 +406,7 @@ Required for: | Method | Count | Result | Executions | |--------|-------|--------|------------| | Unit tests | 154 | ✓ passing | - | -| Integration (CLI) | 179 | ✓ passing | - | +| Integration (CLI) | 185 | ✓ passing | - | | Library API | 10 | ✓ passing | - | | Proptest | 30 | ✓ passing | ~7500/run | | cargo-fuzz | 4 targets | ✓ 0 crashes | ~70M total | diff --git a/docs/benchmarks/RESULTS.md b/docs/benchmarks/RESULTS.md index 0bc52f8..220c994 100644 --- a/docs/benchmarks/RESULTS.md +++ b/docs/benchmarks/RESULTS.md @@ -1,6 +1,6 @@ # Benchmark Results -Raw benchmark data for darwin-timeout performance claims. +Raw benchmark data for procguard performance claims. ## Test Environment @@ -20,10 +20,10 @@ Full machine specs: [machine_specs.json](machine_specs.json) | Binary | Mean | Std Dev | Runs | |--------|------|---------|------| -| darwin-timeout | 3.6ms | ±0.2ms | 250 | +| procguard | 3.6ms | ±0.2ms | 250 | | GNU timeout | 4.2ms | ±0.2ms | 250 | -**darwin-timeout is 18% faster** (1.18x) +**procguard is 18% faster** (1.18x) Raw data: - [run1_startup.json](run1_startup.json) @@ -38,7 +38,7 @@ Raw data: | Binary | Mean | Std Dev | |--------|------|---------| -| darwin-timeout | 1.014s | ±0.003s | +| procguard | 1.014s | ±0.003s | | GNU timeout | 1.017s | ±0.001s | Both implementations are equally precise. @@ -72,7 +72,7 @@ Raw data: [retry_overhead.json](retry_overhead.json) ## Binary Size ``` -darwin-timeout: ~100KB (101984 bytes) +procguard: ~100KB (101984 bytes) GNU coreutils: 15.7MB Ratio: 157x smaller ``` @@ -88,6 +88,6 @@ cargo build --release # Run benchmarks hyperfine --warmup 10 -N --runs 50 \ - -n "darwin-timeout" "./target/release/timeout 1 true" \ + -n "procguard" "./target/release/timeout 1 true" \ -n "GNU timeout" "gtimeout 1 true" ``` diff --git a/docs/benchmarks/precision_1s.json b/docs/benchmarks/precision_1s.json index e91cb1d..71974c5 100644 --- a/docs/benchmarks/precision_1s.json +++ b/docs/benchmarks/precision_1s.json @@ -1,7 +1,7 @@ { "results": [ { - "command": "darwin-timeout", + "command": "procguard", "mean": 1.0135828, "stddev": 0.003232774461116362, "median": 1.014322229, diff --git a/docs/benchmarks/run1_startup.json b/docs/benchmarks/run1_startup.json index f129999..7eab25d 100644 --- a/docs/benchmarks/run1_startup.json +++ b/docs/benchmarks/run1_startup.json @@ -1,7 +1,7 @@ { "results": [ { - "command": "darwin-timeout", + "command": "procguard", "mean": 0.00361852332, "stddev": 0.00019912190849002992, "median": 0.0035734375000000002, diff --git a/docs/benchmarks/run2_startup.json b/docs/benchmarks/run2_startup.json index 9d681bd..659add6 100644 --- a/docs/benchmarks/run2_startup.json +++ b/docs/benchmarks/run2_startup.json @@ -1,7 +1,7 @@ { "results": [ { - "command": "darwin-timeout", + "command": "procguard", "mean": 0.003538356700000001, "stddev": 0.00017910463124485963, "median": 0.0035014580000000003, diff --git a/docs/benchmarks/run3_startup.json b/docs/benchmarks/run3_startup.json index 1752adf..6476bc5 100644 --- a/docs/benchmarks/run3_startup.json +++ b/docs/benchmarks/run3_startup.json @@ -1,7 +1,7 @@ { "results": [ { - "command": "darwin-timeout", + "command": "procguard", "mean": 0.003613129119999999, "stddev": 0.00029157599477320714, "median": 0.0035311665000000002, diff --git a/docs/benchmarks/run4_startup.json b/docs/benchmarks/run4_startup.json index ddb2ac7..2c4979c 100644 --- a/docs/benchmarks/run4_startup.json +++ b/docs/benchmarks/run4_startup.json @@ -1,7 +1,7 @@ { "results": [ { - "command": "darwin-timeout", + "command": "procguard", "mean": 0.0035354815800000008, "stddev": 0.00018037114998961198, "median": 0.0034874375, diff --git a/docs/benchmarks/run5_startup.json b/docs/benchmarks/run5_startup.json index 94692b0..a1b12de 100644 --- a/docs/benchmarks/run5_startup.json +++ b/docs/benchmarks/run5_startup.json @@ -1,7 +1,7 @@ { "results": [ { - "command": "darwin-timeout", + "command": "procguard", "mean": 0.0035461383400000007, "stddev": 0.00018222240566995968, "median": 0.003490125, diff --git a/docs/json-output.md b/docs/json-output.md index 0da6d9b..b5376b3 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -12,10 +12,10 @@ Output is a single JSON object on stdout. The command's own stdout/stderr pass t ## Schema Version -All JSON output includes a `schema_version` field. The current version is **7**. +All JSON output includes a `schema_version` field. The current version is **8**. ```json -{"schema_version":7,"status":"completed",...} +{"schema_version":8,"status":"completed",...} ``` Schema changes: @@ -27,18 +27,19 @@ Schema changes: - **v5**: Added `timeout_reason` field to distinguish timeout types (`wall_clock` vs `stdin_idle`) - **v6**: Added `limits` object describing configured resource limits - **v7**: Added `memory_limit` status (`limit_bytes`, `actual_bytes`) +- **v8**: Added `clock` field for time measurement mode (`wall` vs `active`) ## Status Types The `status` field indicates what happened: -| Status | Meaning | -|--------|---------| -| `completed` | Command finished before timeout | -| `timeout` | Command was killed due to timeout | -| `memory_limit` | Command exceeded `--mem-limit` | +| Status | Meaning | +| ------------------ | ------------------------------------------------------------------------------- | +| `completed` | Command finished before timeout | +| `timeout` | Command was killed due to timeout | +| `memory_limit` | Command exceeded `--mem-limit` | | `signal_forwarded` | timeout received a signal (SIGTERM/SIGINT/SIGHUP) and forwarded it to the child | -| `error` | timeout itself failed (command not found, permission denied, etc.) | +| `error` | timeout itself failed (command not found, permission denied, etc.) | ## Response Formats @@ -48,8 +49,9 @@ Command finished normally before the timeout. ```json { - "schema_version": 7, + "schema_version": 8, "status": "completed", + "clock": "wall", "exit_code": 0, "elapsed_ms": 1523, "user_time_ms": 45, @@ -58,15 +60,16 @@ Command finished normally before the timeout. } ``` -| Field | Type | Description | -|-------|------|-------------| -| `schema_version` | integer | Schema version (currently 7) | -| `status` | string | Always `"completed"` | -| `exit_code` | integer | Command's exit code (0-255) | -| `elapsed_ms` | integer | Wall-clock time in milliseconds | -| `user_time_ms` | integer | User CPU time in milliseconds | -| `system_time_ms` | integer | System (kernel) CPU time in milliseconds | -| `max_rss_kb` | integer | Peak memory usage in kilobytes | +| Field | Type | Description | +| ---------------- | ------- | -------------------------------------------------------------- | +| `schema_version` | integer | Schema version (currently 8) | +| `status` | string | Always `"completed"` | +| `clock` | string | Time measurement mode: `"wall"` (default) or `"active"` | +| `exit_code` | integer | Command's exit code (0-255) | +| `elapsed_ms` | integer | Elapsed time in milliseconds (wall or active based on `clock`) | +| `user_time_ms` | integer | User CPU time in milliseconds | +| `system_time_ms` | integer | System (kernel) CPU time in milliseconds | +| `max_rss_kb` | integer | Peak memory usage in kilobytes | ### timeout @@ -74,8 +77,9 @@ Command was killed because it exceeded the time limit. ```json { - "schema_version": 7, + "schema_version": 8, "status": "timeout", + "clock": "wall", "timeout_reason": "wall_clock", "signal": "SIGTERM", "signal_num": 15, @@ -89,20 +93,20 @@ Command was killed because it exceeded the time limit. } ``` -| Field | Type | Description | -|-------|------|-------------| -| `schema_version` | integer | Schema version (currently 7) | -| `status` | string | Always `"timeout"` | -| `timeout_reason` | string | Why timeout occurred: `"wall_clock"` (main timeout) or `"stdin_idle"` (stdin timeout via `-S`) | -| `signal` | string | Signal sent to command (e.g., `"SIGTERM"`, `"SIGKILL"`) | -| `signal_num` | integer | Signal number (e.g., 15 for SIGTERM, 9 for SIGKILL) | -| `killed` | boolean | `true` if escalated to SIGKILL via `--kill-after` | -| `command_exit_code` | integer | Command's exit code, or -1 if killed by signal | -| `exit_code` | integer | timeout's exit code (124 by default, or custom via `--timeout-exit-code`) | -| `elapsed_ms` | integer | Wall-clock time in milliseconds | -| `user_time_ms` | integer | User CPU time in milliseconds | -| `system_time_ms` | integer | System (kernel) CPU time in milliseconds | -| `max_rss_kb` | integer | Peak memory usage in kilobytes | +| Field | Type | Description | +| ------------------- | ------- | ---------------------------------------------------------------------------------------------- | +| `schema_version` | integer | Schema version (currently 8) | +| `status` | string | Always `"timeout"` | +| `timeout_reason` | string | Why timeout occurred: `"wall_clock"` (main timeout) or `"stdin_idle"` (stdin timeout via `-S`) | +| `signal` | string | Signal sent to command (e.g., `"SIGTERM"`, `"SIGKILL"`) | +| `signal_num` | integer | Signal number (e.g., 15 for SIGTERM, 9 for SIGKILL) | +| `killed` | boolean | `true` if escalated to SIGKILL via `--kill-after` | +| `command_exit_code` | integer | Command's exit code, or -1 if killed by signal | +| `exit_code` | integer | procguard's exit code (124 by default, or custom via `--timeout-exit-code`) | +| `elapsed_ms` | integer | Wall-clock time in milliseconds | +| `user_time_ms` | integer | User CPU time in milliseconds | +| `system_time_ms` | integer | System (kernel) CPU time in milliseconds | +| `max_rss_kb` | integer | Peak memory usage in kilobytes | #### Stdin Idle Timeout @@ -110,8 +114,9 @@ When using `-S/--stdin-timeout`, a timeout can occur due to stdin inactivity: ```json { - "schema_version": 7, + "schema_version": 8, "status": "timeout", + "clock": "wall", "timeout_reason": "stdin_idle", "signal": "SIGTERM", "signal_num": 15, @@ -142,8 +147,9 @@ When `--on-timeout` is specified, additional fields describe the hook execution: ```json { - "schema_version": 7, + "schema_version": 8, "status": "timeout", + "clock": "wall", "timeout_reason": "wall_clock", "signal": "SIGTERM", "signal_num": 15, @@ -161,12 +167,12 @@ When `--on-timeout` is specified, additional fields describe the hook execution: } ``` -| Field | Type | Description | -|-------|------|-------------| -| `hook_ran` | boolean | Whether the hook was executed | -| `hook_exit_code` | integer \| null | Hook's exit code, or `null` if timed out or failed to start | -| `hook_timed_out` | boolean | Whether the hook exceeded `--on-timeout-limit` | -| `hook_elapsed_ms` | integer | How long the hook ran in milliseconds | +| Field | Type | Description | +| ----------------- | --------------- | ----------------------------------------------------------- | +| `hook_ran` | boolean | Whether the hook was executed | +| `hook_exit_code` | integer \| null | Hook's exit code, or `null` if timed out or failed to start | +| `hook_timed_out` | boolean | Whether the hook exceeded `--on-timeout-limit` | +| `hook_elapsed_ms` | integer | How long the hook ran in milliseconds | #### With --retry @@ -174,8 +180,9 @@ When `--retry N` is specified (N > 0), additional fields track retry attempts: ```json { - "schema_version": 7, + "schema_version": 8, "status": "completed", + "clock": "wall", "exit_code": 0, "elapsed_ms": 45000, "user_time_ms": 100, @@ -183,36 +190,37 @@ When `--retry N` is specified (N > 0), additional fields track retry attempts: "max_rss_kb": 8432, "attempts": 3, "attempt_results": [ - {"status": "timeout", "exit_code": null, "elapsed_ms": 30000}, - {"status": "timeout", "exit_code": null, "elapsed_ms": 30000}, - {"status": "completed", "exit_code": 0, "elapsed_ms": 15000} + { "status": "timeout", "exit_code": null, "elapsed_ms": 30000 }, + { "status": "timeout", "exit_code": null, "elapsed_ms": 30000 }, + { "status": "completed", "exit_code": 0, "elapsed_ms": 15000 } ] } ``` -| Field | Type | Description | -|-------|------|-------------| -| `attempts` | integer | Total number of attempts made | -| `attempt_results` | array | Per-attempt results (see below) | +| Field | Type | Description | +| ----------------- | ------- | ------------------------------- | +| `attempts` | integer | Total number of attempts made | +| `attempt_results` | array | Per-attempt results (see below) | Each element in `attempt_results` contains: -| Field | Type | Description | -|-------|------|-------------| -| `status` | string | `"completed"`, `"timeout"`, or `"signal_forwarded"` | -| `exit_code` | integer \| null | Exit code for this attempt, or `null` if timed out | -| `elapsed_ms` | integer | Duration of this attempt in milliseconds | +| Field | Type | Description | +| ------------ | --------------- | --------------------------------------------------- | +| `status` | string | `"completed"`, `"timeout"`, or `"signal_forwarded"` | +| `exit_code` | integer \| null | Exit code for this attempt, or `null` if timed out | +| `elapsed_ms` | integer | Duration of this attempt in milliseconds | **Note:** `attempts` and `attempt_results` fields are only present when `--retry N` is specified with N > 0. ### signal_forwarded -timeout received a signal (e.g., from `docker stop`, `kill`, or Ctrl+C) and forwarded it to the child process. +procguard received a signal (e.g., from `docker stop`, `kill`, or Ctrl+C) and forwarded it to the child process. ```json { - "schema_version": 7, + "schema_version": 8, "status": "signal_forwarded", + "clock": "wall", "signal": "SIGTERM", "signal_num": 15, "command_exit_code": 143, @@ -224,18 +232,19 @@ timeout received a signal (e.g., from `docker stop`, `kill`, or Ctrl+C) and forw } ``` -| Field | Type | Description | -|-------|------|-------------| -| `schema_version` | integer | Schema version (currently 7) | -| `status` | string | Always `"signal_forwarded"` | -| `signal` | string | Signal that was forwarded | -| `signal_num` | integer | Signal number | -| `command_exit_code` | integer | Command's exit code after receiving the signal | -| `exit_code` | integer | timeout's exit code (usually 128 + signal number) | -| `elapsed_ms` | integer | Wall-clock time in milliseconds | -| `user_time_ms` | integer | User CPU time in milliseconds | -| `system_time_ms` | integer | System (kernel) CPU time in milliseconds | -| `max_rss_kb` | integer | Peak memory usage in kilobytes | +| Field | Type | Description | +| ------------------- | ------- | --------------------------------------------------- | +| `schema_version` | integer | Schema version (currently 8) | +| `status` | string | Always `"signal_forwarded"` | +| `clock` | string | Time measurement mode: `"wall"` or `"active"` | +| `signal` | string | Signal that was forwarded | +| `signal_num` | integer | Signal number | +| `command_exit_code` | integer | Command's exit code after receiving the signal | +| `exit_code` | integer | procguard's exit code (usually 128 + signal number) | +| `elapsed_ms` | integer | Wall-clock time in milliseconds | +| `user_time_ms` | integer | User CPU time in milliseconds | +| `system_time_ms` | integer | System (kernel) CPU time in milliseconds | +| `max_rss_kb` | integer | Peak memory usage in kilobytes | ### memory_limit @@ -243,8 +252,9 @@ Command was killed because it exceeded `--mem-limit`. ```json { - "schema_version": 7, + "schema_version": 8, "status": "memory_limit", + "clock": "wall", "signal": "SIGKILL", "signal_num": 9, "killed": true, @@ -261,11 +271,11 @@ Command was killed because it exceeded `--mem-limit`. ### error -timeout itself encountered an error. +procguard itself encountered an error. ```json { - "schema_version": 7, + "schema_version": 8, "status": "error", "error": "command not found: nonexistent_cmd", "exit_code": 127, @@ -273,13 +283,13 @@ timeout itself encountered an error. } ``` -| Field | Type | Description | -|-------|------|-------------| -| `schema_version` | integer | Schema version (currently 7) | -| `status` | string | Always `"error"` | -| `error` | string | Human-readable error message | -| `exit_code` | integer | Exit code (125=internal error, 126=not executable, 127=not found) | -| `elapsed_ms` | integer | Wall-clock time in milliseconds | +| Field | Type | Description | +| ---------------- | ------- | ----------------------------------------------------------------- | +| `schema_version` | integer | Schema version (currently 8) | +| `status` | string | Always `"error"` | +| `error` | string | Human-readable error message | +| `exit_code` | integer | Exit code (125=internal error, 126=not executable, 127=not found) | +| `elapsed_ms` | integer | Wall-clock time in milliseconds | Note: Error responses do **not** include resource usage fields since the command may not have started. @@ -287,11 +297,11 @@ Note: Error responses do **not** include resource usage fields since the command Schema v3 added resource usage fields from the underlying `wait4()` syscall: -| Field | Description | Notes | -|-------|-------------|-------| -| `user_time_ms` | CPU time spent in user mode | Time the command spent executing application code | +| Field | Description | Notes | +| ---------------- | ----------------------------- | --------------------------------------------------------- | +| `user_time_ms` | CPU time spent in user mode | Time the command spent executing application code | | `system_time_ms` | CPU time spent in kernel mode | Time spent in system calls (I/O, memory allocation, etc.) | -| `max_rss_kb` | Peak resident set size | Maximum physical memory used, in kilobytes | +| `max_rss_kb` | Peak resident set size | Maximum physical memory used, in kilobytes | ### Precision Notes @@ -304,7 +314,7 @@ Schema v3 added resource usage fields from the underlying `wait4()` syscall: **CPU-bound process:** ```json -{"user_time_ms": 4500, "system_time_ms": 100, "elapsed_ms": 4650} +{ "user_time_ms": 4500, "system_time_ms": 100, "elapsed_ms": 4650 } ``` High user time, low system time, elapsed ≈ user + system = CPU-bound. @@ -312,7 +322,7 @@ High user time, low system time, elapsed ≈ user + system = CPU-bound. **I/O-bound process:** ```json -{"user_time_ms": 50, "system_time_ms": 200, "elapsed_ms": 5000} +{ "user_time_ms": 50, "system_time_ms": 200, "elapsed_ms": 5000 } ``` Low CPU times but high elapsed = waiting on I/O. @@ -320,7 +330,7 @@ Low CPU times but high elapsed = waiting on I/O. **Memory-intensive process:** ```json -{"max_rss_kb": 524288} +{ "max_rss_kb": 524288 } ``` 512 MB peak memory usage. diff --git a/docs/resource-limits.md b/docs/resource-limits.md index 1edd7d1..c8aa2dd 100644 --- a/docs/resource-limits.md +++ b/docs/resource-limits.md @@ -1,6 +1,6 @@ # Resource Limits -`darwin-timeout` provides three complementary resource limiting mechanisms. Each uses a different enforcement strategy with distinct trade-offs. +`procguard` provides three complementary resource limiting mechanisms. Each uses a different enforcement strategy with distinct trade-offs. ## Quick Reference @@ -220,7 +220,7 @@ With `--json`, limits appear in the output: ```json { - "schema_version": 7, + "schema_version": 8, "status": "completed", "limits": { "mem_bytes": 1073741824, diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock deleted file mode 100644 index 0535906..0000000 --- a/fuzz/Cargo.lock +++ /dev/null @@ -1,113 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - -[[package]] -name = "cc" -version = "1.2.49" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "darwin-timeout" -version = "1.4.0" -dependencies = [ - "libc", -] - -[[package]] -name = "darwin-timeout-fuzz" -version = "0.0.0" -dependencies = [ - "darwin-timeout", - "libfuzzer-sys", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom", - "libc", -] - -[[package]] -name = "libc" -version = "0.2.178" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" - -[[package]] -name = "libfuzzer-sys" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" -dependencies = [ - "arbitrary", - "cc", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 00973d7..a8ea17c 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "darwin-timeout-fuzz" +name = "procguard-fuzz" version = "0.0.0" publish = false edition = "2024" @@ -10,7 +10,7 @@ cargo-fuzz = true [dependencies] libfuzzer-sys = "0.4" -[dependencies.darwin-timeout] +[dependencies.procguard] path = ".." # Prevent this from interfering with workspaces diff --git a/fuzz/fuzz_targets/parse_args.rs b/fuzz/fuzz_targets/parse_args.rs index 61cff5a..b671c03 100644 --- a/fuzz/fuzz_targets/parse_args.rs +++ b/fuzz/fuzz_targets/parse_args.rs @@ -34,5 +34,5 @@ fuzz_target!(|data: &[u8]| { } /* parse_from_slice must not panic on any argument combination */ - let _ = darwin_timeout::args::parse_from_slice(&args); + let _ = procguard::args::parse_from_slice(&args); }); diff --git a/fuzz/fuzz_targets/parse_duration.rs b/fuzz/fuzz_targets/parse_duration.rs index a548abf..d3b10cb 100644 --- a/fuzz/fuzz_targets/parse_duration.rs +++ b/fuzz/fuzz_targets/parse_duration.rs @@ -15,6 +15,6 @@ fuzz_target!(|data: &[u8]| { /* convert to str - invalid UTF-8 should be handled gracefully */ if let Ok(s) = core::str::from_utf8(data) { /* parse_duration must not panic on any valid UTF-8 string */ - let _ = darwin_timeout::duration::parse_duration(s); + let _ = procguard::duration::parse_duration(s); } }); diff --git a/fuzz/fuzz_targets/parse_mem_limit.rs b/fuzz/fuzz_targets/parse_mem_limit.rs index b460122..4bdb10b 100644 --- a/fuzz/fuzz_targets/parse_mem_limit.rs +++ b/fuzz/fuzz_targets/parse_mem_limit.rs @@ -14,6 +14,6 @@ use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { if let Ok(s) = core::str::from_utf8(data) { /* parse_mem_limit must not panic on any valid UTF-8 string */ - let _ = darwin_timeout::rlimit::parse_mem_limit(s); + let _ = procguard::rlimit::parse_mem_limit(s); } }); diff --git a/fuzz/fuzz_targets/parse_signal.rs b/fuzz/fuzz_targets/parse_signal.rs index bb28992..719899e 100644 --- a/fuzz/fuzz_targets/parse_signal.rs +++ b/fuzz/fuzz_targets/parse_signal.rs @@ -14,6 +14,6 @@ use libfuzzer_sys::fuzz_target; fuzz_target!(|data: &[u8]| { if let Ok(s) = core::str::from_utf8(data) { /* parse_signal must not panic on any valid UTF-8 string */ - let _ = darwin_timeout::signal::parse_signal(s); + let _ = procguard::signal::parse_signal(s); } }); diff --git a/scripts/benchmark.sh b/scripts/benchmark.sh index 4c05eb5..b2460f9 100755 --- a/scripts/benchmark.sh +++ b/scripts/benchmark.sh @@ -68,7 +68,7 @@ echo "" if [[ -n "$GNU_TIMEOUT" ]]; then hyperfine --warmup 10 -N --runs 30 \ - -n "darwin-timeout" "$TIMEOUT_BIN 1 true" \ + -n "procguard" "$TIMEOUT_BIN 1 true" \ -n "GNU timeout" "$GNU_TIMEOUT 1 true" else hyperfine --warmup 10 -N --runs 30 "$TIMEOUT_BIN 1 true" @@ -84,7 +84,7 @@ echo "" echo -e "${CYAN}100ms timeout:${NC}" if [[ -n "$GNU_TIMEOUT" ]]; then hyperfine --warmup 5 -N -i --runs 15 \ - -n "darwin-timeout" "$TIMEOUT_BIN 0.1 sleep 10" \ + -n "procguard" "$TIMEOUT_BIN 0.1 sleep 10" \ -n "GNU timeout" "$GNU_TIMEOUT 0.1 sleep 10" else hyperfine --warmup 5 -N -i --runs 15 "$TIMEOUT_BIN 0.1 sleep 10" @@ -94,7 +94,7 @@ echo "" echo -e "${CYAN}1s timeout:${NC}" if [[ -n "$GNU_TIMEOUT" ]]; then hyperfine --warmup 3 -N -i --runs 10 \ - -n "darwin-timeout" "$TIMEOUT_BIN 1 sleep 10" \ + -n "procguard" "$TIMEOUT_BIN 1 sleep 10" \ -n "GNU timeout" "$GNU_TIMEOUT 1 sleep 10" else hyperfine --warmup 3 -N -i --runs 10 "$TIMEOUT_BIN 1 sleep 10" @@ -146,8 +146,8 @@ echo "" if [[ -n "$GNU_TIMEOUT" ]]; then echo -e "${CYAN}All three implementations (startup):${NC}" hyperfine --warmup 10 -N --runs 30 \ - -n "darwin-timeout (wall, default)" "$TIMEOUT_BIN 1 true" \ - -n "darwin-timeout (active)" "$TIMEOUT_BIN -c active 1 true" \ + -n "procguard (wall, default)" "$TIMEOUT_BIN 1 true" \ + -n "procguard (active)" "$TIMEOUT_BIN -c active 1 true" \ -n "GNU timeout" "$GNU_TIMEOUT 1 true" echo "" else diff --git a/scripts/build-universal.sh b/scripts/build-universal.sh index f29a569..1b045dc 100755 --- a/scripts/build-universal.sh +++ b/scripts/build-universal.sh @@ -16,7 +16,8 @@ # ./scripts/build-universal.sh --minimal # nightly build-std (~~100KB universal) # # Output: -# target/universal/timeout - The universal binary +# target/universal/procguard - The universal binary +# target/universal/timeout - Symlink to procguard # set -euo pipefail @@ -32,7 +33,7 @@ if [[ "${1:-}" == "--minimal" ]]; then MINIMAL=true fi -echo "=== Building timeout universal binary ===" +echo "=== Building procguard universal binary ===" if $MINIMAL; then echo " Mode: minimal (nightly + build-std)" else @@ -98,17 +99,20 @@ mkdir -p target/universal # Combine with lipo echo "Creating universal binary with lipo..." lipo -create \ - target/aarch64-apple-darwin/release/timeout \ - target/x86_64-apple-darwin/release/timeout \ - -output target/universal/timeout + target/aarch64-apple-darwin/release/procguard \ + target/x86_64-apple-darwin/release/procguard \ + -output target/universal/procguard # aggressive strip: -x removes local symbols, -S removes debug symbols # (release profile already strips, this catches anything lipo preserved) -strip -x -S target/universal/timeout 2>/dev/null || true +strip -x -S target/universal/procguard 2>/dev/null || true # Optional: ad-hoc codesign for faster first launch echo "Signing binary..." -codesign -s - target/universal/timeout 2>/dev/null || true +codesign -s - target/universal/procguard 2>/dev/null || true + +# Create timeout symlink for GNU compatibility +ln -sf procguard target/universal/timeout echo "" echo "=== Build complete ===" @@ -116,20 +120,23 @@ echo "" # Show results echo "Binary info:" -file target/universal/timeout +file target/universal/procguard echo "" echo "Size:" -ls -lh target/universal/timeout +ls -lh target/universal/procguard echo "" echo "Architectures:" -lipo -info target/universal/timeout +lipo -info target/universal/procguard echo "" # Quick sanity test echo "Sanity test:" +target/universal/procguard --version target/universal/timeout --version echo "" -echo "Universal binary available at: target/universal/timeout" +echo "Universal binaries available at:" +echo " target/universal/procguard (primary)" +echo " target/universal/timeout (GNU-compatible symlink)" diff --git a/scripts/pre-commit b/scripts/pre-commit index a0c4a2b..e8538a6 100755 --- a/scripts/pre-commit +++ b/scripts/pre-commit @@ -1,6 +1,6 @@ #!/bin/bash # -# pre-commit hook for darwin-timeout +# pre-commit hook for procguard # # runs fast checks before commit: fmt + clippy only. # full tests run in CI (too slow for pre-commit). diff --git a/scripts/pre-push b/scripts/pre-push index a9f6e6f..2a56af2 100755 --- a/scripts/pre-push +++ b/scripts/pre-push @@ -1,6 +1,6 @@ #!/bin/bash # -# pre-push hook for darwin-timeout +# pre-push hook for procguard # # runs full validation before push: fmt + clippy + quick tests. # slower than pre-commit, but catches more issues before CI. diff --git a/scripts/setup-hooks.sh b/scripts/setup-hooks.sh index 9a50d25..0978993 100755 --- a/scripts/setup-hooks.sh +++ b/scripts/setup-hooks.sh @@ -1,6 +1,6 @@ #!/bin/bash # -# Install git hooks for darwin-timeout development. +# Install git hooks for procguard development. # # Usage: ./scripts/setup-hooks.sh # diff --git a/scripts/verify-all.sh b/scripts/verify-all.sh index f95de76..75bc26e 100755 --- a/scripts/verify-all.sh +++ b/scripts/verify-all.sh @@ -16,7 +16,7 @@ if [[ "$1" == "--quick" ]]; then QUICK=true fi -echo "=== darwin-timeout verification suite ===" +echo "=== procguard verification suite ===" echo "" # static analysis diff --git a/src/args.rs b/src/args.rs index 8df8ac6..bb10df5 100644 --- a/src/args.rs +++ b/src/args.rs @@ -62,6 +62,25 @@ fn get_args_from_darwin() -> Vec { } } +/// Get argv[0] (program name) from Darwin's _NSGetArgv. +/// Used for dual-binary detection: "procguard" vs "timeout" alias. +pub fn get_argv0() -> Option { + // SAFETY: _NSGetArgc/_NSGetArgv always return valid pointers on macOS. + #[allow(clippy::multiple_unsafe_ops_per_block)] + unsafe { + let argc = *_NSGetArgc(); + if argc < 1 { + return None; + } + let argv = *_NSGetArgv(); + let arg_ptr = *argv.offset(0); + if arg_ptr.is_null() { + return None; + } + Some(CStr::from_ptr(arg_ptr).to_string_lossy().into_owned()) + } +} + /// Parsed argument - either borrowed from argv or owned (env var / embedded value) #[derive(Debug, Clone)] pub enum ArgValue<'a> { @@ -134,6 +153,7 @@ pub struct Args<'a> { pub on_timeout: Option>, pub on_timeout_limit: ArgValue<'a>, pub confine: Confine, + pub confine_specified: bool, /* true if user explicitly set -c/--confine */ pub wait_for_file: Option>, pub wait_for_file_timeout: Option>, pub retry: Option>, @@ -164,6 +184,7 @@ pub struct OwnedArgs { pub on_timeout: Option, pub on_timeout_limit: String, pub confine: Confine, + pub confine_specified: bool, /* true if user explicitly set -c/--confine */ pub wait_for_file: Option, pub wait_for_file_timeout: Option, pub retry: Option, @@ -195,6 +216,7 @@ impl<'a> Args<'a> { on_timeout: self.on_timeout.map(|v| v.into_owned()), on_timeout_limit: self.on_timeout_limit.into_owned(), confine: self.confine, + confine_specified: self.confine_specified, wait_for_file: self.wait_for_file.map(|v| v.into_owned()), wait_for_file_timeout: self.wait_for_file_timeout.map(|v| v.into_owned()), retry: self.retry.map(|v| v.into_owned()), @@ -401,12 +423,14 @@ pub fn parse_from_slice<'a>(args: &'a [String]) -> Result, ParseError> result.confine = Confine::from_str(val).ok_or_else(|| ParseError { message: format!("invalid confine mode: '{}' (use 'wall' or 'active')", val), })?; + result.confine_specified = true; } s if s.starts_with("--confine=") => { let val = &s[10..]; result.confine = Confine::from_str(val).ok_or_else(|| ParseError { message: format!("invalid confine mode: '{}' (use 'wall' or 'active')", val), })?; + result.confine_specified = true; } "--wait-for-file" => { @@ -648,6 +672,7 @@ pub fn parse_from_slice<'a>(args: &'a [String]) -> Result, ParseError> val ), })?; + result.confine_specified = true; break; } else { i += 1; @@ -661,6 +686,7 @@ pub fn parse_from_slice<'a>(args: &'a [String]) -> Result, ParseError> val ), })?; + result.confine_specified = true; } } b'r' => { @@ -767,18 +793,21 @@ where } fn print_version() { - crate::io::print_str("timeout "); + crate::io::print_str("procguard "); crate::io::print_str(env!("CARGO_PKG_VERSION")); crate::io::print_str( - "\nCopyright (c) 2025 Alexandre Bouveur\nLicense: MIT \n", + "\nThe formally verified process supervisor for macOS.\nCopyright (c) 2025 Alexandre Bouveur\nLicense: MIT \n", ); } fn print_help() { crate::io::print_str( - r#"Usage: timeout [OPTIONS] DURATION COMMAND [ARG]... + r#"Usage: procguard [OPTIONS] DURATION COMMAND [ARG]... + +The formally verified process supervisor for macOS. +Provides timeout enforcement, resource limits, and process lifecycle control. -Enforce a strict wall-clock deadline on a command (sleep-aware). +When invoked as 'timeout' (symlink), defaults to --confine active for GNU compatibility. Arguments: DURATION Time before sending signal (30, 30s, 100ms, 500us, 1.5m, 2h, 1d) @@ -791,7 +820,7 @@ Options: -p, --preserve-status Exit with same status as COMMAND, even on timeout -f, --foreground Allow COMMAND to read from TTY and get TTY signals -v, --verbose Diagnose to stderr any signal sent upon timeout - -q, --quiet Suppress timeout's own diagnostic output to stderr + -q, --quiet Suppress procguard's own diagnostic output to stderr --timeout-exit-code Exit with CODE instead of 124 when timeout occurs --on-timeout Run CMD before sending the timeout signal (%p = PID) --on-timeout-limit Timeout for the --on-timeout hook command [default: 5s] @@ -818,14 +847,17 @@ Options: --cpu-percent Throttle CPU to PCT via SIGSTOP/SIGCONT (100 = 1 core, 400 = 4 cores; low values may stutter) +Aliases: + timeout GNU-compatible alias (defaults to --confine active) + Exit status: 124 if COMMAND times out, and --preserve-status is not specified 124 if --wait-for-file times out 124 if --stdin-timeout triggers (stdin idle) - 125 if the timeout command itself fails + 125 if the procguard command itself fails 126 if COMMAND is found but cannot be invoked 127 if COMMAND cannot be found - 137 if COMMAND (or timeout itself) is sent SIGKILL (128+9) + 137 if COMMAND (or procguard itself) is sent SIGKILL (128+9) the exit status of COMMAND otherwise Environment: @@ -847,7 +879,7 @@ mod tests { #[test] fn test_minimal_args() { - let args = try_parse_from(["timeout", "5", "sleep", "10"]).unwrap(); + let args = try_parse_from(["procguard", "5", "sleep", "10"]).unwrap(); assert_eq!(args.duration, Some("5".to_string())); assert_eq!(args.command, Some("sleep".to_string())); assert_eq!(args.args, vec!["10"]); @@ -924,45 +956,45 @@ mod tests { #[test] fn test_quiet_verbose_conflict() { - let result = try_parse_from(["timeout", "-q", "-v", "5s", "cmd"]); + let result = try_parse_from(["procguard", "-q", "-v", "5s", "cmd"]); assert!(result.is_err(), "-q and -v should be mutually exclusive"); } #[test] fn test_command_with_dashes() { - let args = try_parse_from(["timeout", "5", "--", "-c", "echo", "hello"]).unwrap(); + let args = try_parse_from(["procguard", "5", "--", "-c", "echo", "hello"]).unwrap(); assert_eq!(args.command, Some("-c".to_string())); assert_eq!(args.args, vec!["echo", "hello"]); } #[test] fn test_json_flag() { - let args = try_parse_from(["timeout", "--json", "5s", "sleep", "1"]).unwrap(); + let args = try_parse_from(["procguard", "--json", "5s", "sleep", "1"]).unwrap(); assert!(args.json); assert_eq!(args.duration, Some("5s".to_string())); } #[test] fn test_quiet_flag() { - let args = try_parse_from(["timeout", "-q", "5s", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "-q", "5s", "cmd"]).unwrap(); assert!(args.quiet); } #[test] fn test_timeout_exit_code() { - let args = try_parse_from(["timeout", "--timeout-exit-code", "99", "5s", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "--timeout-exit-code", "99", "5s", "cmd"]).unwrap(); assert_eq!(args.timeout_exit_code, Some(99)); } #[test] fn test_on_timeout() { - let args = try_parse_from(["timeout", "--on-timeout", "echo %p", "5s", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "--on-timeout", "echo %p", "5s", "cmd"]).unwrap(); assert_eq!(args.on_timeout, Some("echo %p".to_string())); } #[test] fn test_short_option_cluster() { - let args = try_parse_from(["timeout", "-pfv", "5s", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "-pfv", "5s", "cmd"]).unwrap(); assert!(args.preserve_status); assert!(args.foreground); assert!(args.verbose); @@ -984,71 +1016,72 @@ mod tests { #[test] fn test_unknown_option() { - let result = try_parse_from(["timeout", "--unknown", "5s", "cmd"]); + let result = try_parse_from(["procguard", "--unknown", "5s", "cmd"]); assert!(result.is_err()); assert!(result.unwrap_err().message.contains("unknown option")); } #[test] fn test_missing_signal_value() { - let result = try_parse_from(["timeout", "-s"]); + let result = try_parse_from(["procguard", "-s"]); assert!(result.is_err()); } #[test] fn test_confine_wall() { - let args = try_parse_from(["timeout", "--confine=wall", "5s", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "--confine=wall", "5s", "cmd"]).unwrap(); assert_eq!(args.confine, Confine::Wall); } #[test] fn test_confine_active() { - let args = try_parse_from(["timeout", "--confine=active", "5s", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "--confine=active", "5s", "cmd"]).unwrap(); assert_eq!(args.confine, Confine::Active); } #[test] fn test_confine_default() { - let args = try_parse_from(["timeout", "5s", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "5s", "cmd"]).unwrap(); assert_eq!(args.confine, Confine::Wall); // default } #[test] fn test_confine_invalid() { - let result = try_parse_from(["timeout", "--confine=invalid", "5s", "cmd"]); + let result = try_parse_from(["procguard", "--confine=invalid", "5s", "cmd"]); assert!(result.is_err()); assert!(result.unwrap_err().message.contains("invalid confine mode")); } #[test] fn test_confine_case_insensitive() { - let args = try_parse_from(["timeout", "--confine=ACTIVE", "5s", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "--confine=ACTIVE", "5s", "cmd"]).unwrap(); assert_eq!(args.confine, Confine::Active); } #[test] fn test_confine_short_flag() { - let args = try_parse_from(["timeout", "-c", "active", "5s", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "-c", "active", "5s", "cmd"]).unwrap(); assert_eq!(args.confine, Confine::Active); } #[test] fn test_confine_short_flag_embedded() { - let args = try_parse_from(["timeout", "-cwall", "5s", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "-cwall", "5s", "cmd"]).unwrap(); assert_eq!(args.confine, Confine::Wall); } #[test] fn test_wait_for_file() { let args = - try_parse_from(["timeout", "--wait-for-file", "/tmp/ready", "5s", "cmd"]).unwrap(); + try_parse_from(["procguard", "--wait-for-file", "/tmp/ready", "5s", "cmd"]).unwrap(); assert_eq!(args.wait_for_file, Some("/tmp/ready".to_string())); assert!(args.wait_for_file_timeout.is_none()); } #[test] fn test_wait_for_file_equals_syntax() { - let args = try_parse_from(["timeout", "--wait-for-file=/tmp/ready", "5s", "cmd"]).unwrap(); + let args = + try_parse_from(["procguard", "--wait-for-file=/tmp/ready", "5s", "cmd"]).unwrap(); assert_eq!(args.wait_for_file, Some("/tmp/ready".to_string())); } @@ -1084,14 +1117,14 @@ mod tests { #[test] fn test_wait_for_file_missing_path() { - let result = try_parse_from(["timeout", "--wait-for-file"]); + let result = try_parse_from(["procguard", "--wait-for-file"]); assert!(result.is_err()); assert!(result.unwrap_err().message.contains("requires a path")); } #[test] fn test_wait_for_file_timeout_missing_duration() { - let result = try_parse_from(["timeout", "--wait-for-file-timeout"]); + let result = try_parse_from(["procguard", "--wait-for-file-timeout"]); assert!(result.is_err()); assert!(result.unwrap_err().message.contains("requires a duration")); } @@ -1100,19 +1133,19 @@ mod tests { #[test] fn test_retry_short_flag() { - let args = try_parse_from(["timeout", "-r", "3", "5s", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "-r", "3", "5s", "cmd"]).unwrap(); assert_eq!(args.retry, Some("3".to_string())); } #[test] fn test_retry_long_flag() { - let args = try_parse_from(["timeout", "--retry", "5", "5s", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "--retry", "5", "5s", "cmd"]).unwrap(); assert_eq!(args.retry, Some("5".to_string())); } #[test] fn test_retry_equals_syntax() { - let args = try_parse_from(["timeout", "--retry=2", "5s", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "--retry=2", "5s", "cmd"]).unwrap(); assert_eq!(args.retry, Some("2".to_string())); } @@ -1135,7 +1168,7 @@ mod tests { #[test] fn test_retry_delay_equals_syntax() { let args = - try_parse_from(["timeout", "--retry=3", "--retry-delay=500ms", "5s", "cmd"]).unwrap(); + try_parse_from(["procguard", "--retry=3", "--retry-delay=500ms", "5s", "cmd"]).unwrap(); assert_eq!(args.retry, Some("3".to_string())); assert_eq!(args.retry_delay, Some("500ms".to_string())); } @@ -1178,28 +1211,28 @@ mod tests { #[test] fn test_retry_with_short_r() { let args = - try_parse_from(["timeout", "-r", "2", "--retry-delay", "1s", "5s", "cmd"]).unwrap(); + try_parse_from(["procguard", "-r", "2", "--retry-delay", "1s", "5s", "cmd"]).unwrap(); assert_eq!(args.retry, Some("2".to_string())); assert_eq!(args.retry_delay, Some("1s".to_string())); } #[test] fn test_retry_missing_count() { - let result = try_parse_from(["timeout", "--retry"]); + let result = try_parse_from(["procguard", "--retry"]); assert!(result.is_err()); assert!(result.unwrap_err().message.contains("requires a count")); } #[test] fn test_retry_delay_missing_duration() { - let result = try_parse_from(["timeout", "--retry-delay"]); + let result = try_parse_from(["procguard", "--retry-delay"]); assert!(result.is_err()); assert!(result.unwrap_err().message.contains("requires a duration")); } #[test] fn test_retry_backoff_missing_multiplier() { - let result = try_parse_from(["timeout", "--retry-backoff"]); + let result = try_parse_from(["procguard", "--retry-backoff"]); assert!(result.is_err()); assert!( result @@ -1236,40 +1269,48 @@ mod tests { #[test] fn test_heartbeat_long_flag() { - let args = try_parse_from(["timeout", "--heartbeat", "60s", "5s", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "--heartbeat", "60s", "5s", "cmd"]).unwrap(); assert_eq!(args.heartbeat, Some("60s".to_string())); } #[test] fn test_heartbeat_short_flag() { - let args = try_parse_from(["timeout", "-H", "30s", "5s", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "-H", "30s", "5s", "cmd"]).unwrap(); assert_eq!(args.heartbeat, Some("30s".to_string())); } #[test] fn test_heartbeat_equals_syntax() { - let args = try_parse_from(["timeout", "--heartbeat=1m", "5s", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "--heartbeat=1m", "5s", "cmd"]).unwrap(); assert_eq!(args.heartbeat, Some("1m".to_string())); } #[test] fn test_heartbeat_missing_duration() { - let result = try_parse_from(["timeout", "--heartbeat"]); + let result = try_parse_from(["procguard", "--heartbeat"]); assert!(result.is_err()); assert!(result.unwrap_err().message.contains("requires a duration")); } #[test] fn test_heartbeat_short_flag_missing_duration() { - let result = try_parse_from(["timeout", "-H"]); + let result = try_parse_from(["procguard", "-H"]); assert!(result.is_err()); assert!(result.unwrap_err().message.contains("requires a duration")); } #[test] fn test_heartbeat_combined_with_other_flags() { - let args = - try_parse_from(["timeout", "-v", "--json", "--heartbeat", "60s", "5m", "cmd"]).unwrap(); + let args = try_parse_from([ + "procguard", + "-v", + "--json", + "--heartbeat", + "60s", + "5m", + "cmd", + ]) + .unwrap(); assert!(args.verbose); assert!(args.json); assert_eq!(args.heartbeat, Some("60s".to_string())); @@ -1278,7 +1319,7 @@ mod tests { #[test] fn test_heartbeat_short_flag_embedded() { - let args = try_parse_from(["timeout", "-H30s", "5s", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "-H30s", "5s", "cmd"]).unwrap(); assert_eq!(args.heartbeat, Some("30s".to_string())); } @@ -1286,7 +1327,7 @@ mod tests { #[test] fn test_stdin_timeout_long_flag() { - let args = try_parse_from(["timeout", "--stdin-timeout", "30s", "5s", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "--stdin-timeout", "30s", "5s", "cmd"]).unwrap(); assert_eq!(args.stdin_timeout, Some("30s".to_string())); } @@ -1308,13 +1349,13 @@ mod tests { #[test] fn test_stdin_timeout_equals_syntax() { - let args = try_parse_from(["timeout", "--stdin-timeout=1m", "5s", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "--stdin-timeout=1m", "5s", "cmd"]).unwrap(); assert_eq!(args.stdin_timeout, Some("1m".to_string())); } #[test] fn test_stdin_timeout_missing_duration() { - let result = try_parse_from(["timeout", "--stdin-timeout"]); + let result = try_parse_from(["procguard", "--stdin-timeout"]); assert!(result.is_err()); assert!(result.unwrap_err().message.contains("requires a duration")); } @@ -1339,26 +1380,26 @@ mod tests { #[test] fn test_stdin_timeout_with_main_timeout() { - let args = try_parse_from(["timeout", "--stdin-timeout", "30s", "5m", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "--stdin-timeout", "30s", "5m", "cmd"]).unwrap(); assert_eq!(args.stdin_timeout, Some("30s".to_string())); assert_eq!(args.duration, Some("5m".to_string())); } #[test] fn test_stdin_timeout_short_flag() { - let args = try_parse_from(["timeout", "-S", "30s", "5s", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "-S", "30s", "5s", "cmd"]).unwrap(); assert_eq!(args.stdin_timeout, Some("30s".to_string())); } #[test] fn test_stdin_timeout_short_flag_embedded() { - let args = try_parse_from(["timeout", "-S30s", "5s", "cmd"]).unwrap(); + let args = try_parse_from(["procguard", "-S30s", "5s", "cmd"]).unwrap(); assert_eq!(args.stdin_timeout, Some("30s".to_string())); } #[test] fn test_stdin_timeout_short_flag_missing_duration() { - let result = try_parse_from(["timeout", "-S"]); + let result = try_parse_from(["procguard", "-S"]); assert!(result.is_err()); assert!(result.unwrap_err().message.contains("requires a duration")); } diff --git a/src/duration.rs b/src/duration.rs index 60975ab..91668dc 100644 --- a/src/duration.rs +++ b/src/duration.rs @@ -19,7 +19,7 @@ use crate::error::{Result, TimeoutError}; /// # Examples /// /// ``` -/// use darwin_timeout::duration::parse_duration; +/// use procguard::duration::parse_duration; /// use std::time::Duration; /// /// assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30)); diff --git a/src/lib.rs b/src/lib.rs index be2dfb5..7d3d055 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,9 +5,9 @@ * use std for better error messages and easier debugging. */ -//! # darwin-timeout +//! # procguard //! -//! A native macOS/Darwin implementation of the GNU timeout command. +//! The formally verified process supervisor for macOS. //! //! This crate provides both a CLI binary and a library for running commands //! with time limits on macOS. It uses Darwin-specific APIs (`mach_continuous_time`, @@ -23,7 +23,7 @@ //! The primary entry points are [`run_command`] and [`run_with_retry`]: //! //! ```ignore -//! use darwin_timeout::{RunConfig, RunResult, Signal, run_command, setup_signal_forwarding}; +//! use procguard::{RunConfig, RunResult, Signal, run_command, setup_signal_forwarding}; //! use std::time::Duration; //! //! // Set up signal forwarding (optional but recommended) @@ -57,7 +57,7 @@ //! Helper functions for parsing duration and signal specifications: //! //! ```rust -//! use darwin_timeout::{parse_duration, parse_signal, signal::Signal}; +//! use procguard::{parse_duration, parse_signal, signal::Signal}; //! use core::time::Duration; //! //! // Parse duration strings @@ -90,7 +90,7 @@ /* fail fast on unsupported platforms - darwin APIs required */ #[cfg(not(target_os = "macos"))] -compile_error!("darwin-timeout requires macOS (iOS support planned for future release)"); +compile_error!("procguard requires macOS (iOS support planned for future release)"); extern crate alloc; diff --git a/src/main.rs b/src/main.rs index 1c9b0cf..25f685e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,11 @@ * The interesting stuff is in runner.rs. * * --json is for CI. Format is stable, don't change field names. + * + * Dual binary support: + * - "procguard": wall-clock default (survives sleep) + * - "timeout": GNU-compatible active-time default (pauses on sleep) + * Detection is via argv[0] - if invoked as "timeout", defaults to --confine active. */ #![cfg_attr(not(any(debug_assertions, test, doc)), no_std)] @@ -15,14 +20,14 @@ use alloc::vec; use alloc::vec::Vec; use core::fmt::Write as FmtWrite; -use darwin_timeout::args::{OwnedArgs, parse_args}; -use darwin_timeout::duration::parse_duration; -use darwin_timeout::error::exit_codes; -use darwin_timeout::runner::{ +use procguard::args::{Confine, OwnedArgs, parse_args}; +use procguard::duration::parse_duration; +use procguard::error::exit_codes; +use procguard::runner::{ AttemptResult, RunConfig, RunResult, run_with_retry, setup_signal_forwarding, }; -use darwin_timeout::wait::wait_for_file; -use darwin_timeout::{eprintln, println}; +use procguard::wait::wait_for_file; +use procguard::{eprintln, println}; /* import alloc crate in no_std mode */ #[cfg(not(any(debug_assertions, test, doc)))] @@ -159,28 +164,49 @@ fn main() { /* shared implementation */ fn run_main() -> u8 { - let args = match parse_args() { + let mut args = match parse_args() { Ok(args) => args, Err(e) => { - eprintln!("timeout: {}", e); + eprintln!("procguard: {}", e); return exit_codes::INTERNAL_ERROR; } }; - let timeout_env = darwin_timeout::args::get_env(b"TIMEOUT\0"); + /* argv[0] detection: when invoked as "timeout", default to --confine active (GNU behavior) */ + let is_timeout_alias = procguard::args::get_argv0() + .map(|s| { + /* extract basename (e.g., "/usr/local/bin/timeout" -> "timeout") */ + s.rsplit('/').next().unwrap_or(&s) == "timeout" + }) + .unwrap_or(false); + if is_timeout_alias && !args.confine_specified { + args.confine = Confine::Active; + } + + /* binary name for error messages */ + let prog_name = if is_timeout_alias { + "timeout" + } else { + "procguard" + }; + + let timeout_env = procguard::args::get_env(b"TIMEOUT\0"); let (duration_str, command, extra_args) = resolve_args(&args, timeout_env.as_deref()); let (duration_str, command) = match (duration_str, command) { (Some(d), Some(c)) => (d, c), (None, _) => { if !args.quiet { - eprintln!("timeout: missing duration (provide as argument or set TIMEOUT env var)"); + eprintln!( + "{}: missing duration (provide as argument or set TIMEOUT env var)", + prog_name + ); } return exit_codes::INTERNAL_ERROR; } (Some(_), None) => { if !args.quiet { - eprintln!("timeout: missing command"); + eprintln!("{}: missing command", prog_name); } return exit_codes::INTERNAL_ERROR; } @@ -190,7 +216,7 @@ fn run_main() -> u8 { Ok(config) => config, Err(e) => { if !args.quiet { - eprintln!("timeout: {}", e); + eprintln!("{}: {}", prog_name, e); } return e.exit_code(); } @@ -208,7 +234,7 @@ fn run_main() -> u8 { Ok(t) => t, Err(e) => { if !args.quiet { - eprintln!("timeout: invalid --wait-for-file-timeout: {}", e); + eprintln!("{}: invalid --wait-for-file-timeout: {}", prog_name, e); } return exit_codes::INTERNAL_ERROR; } @@ -220,11 +246,11 @@ fn run_main() -> u8 { let secs = d.as_secs(); let tenths = d.subsec_millis() / 100; eprintln!( - "timeout: waiting for file '{}' (timeout: {}.{}s)", - path, secs, tenths + "{}: waiting for file '{}' (timeout: {}.{}s)", + prog_name, path, secs, tenths ); } - None => eprintln!("timeout: waiting for file '{}' (no timeout)", path), + None => eprintln!("{}: waiting for file '{}' (no timeout)", prog_name, path), } } @@ -232,13 +258,13 @@ fn run_main() -> u8 { if args.json { print_json_error(&e, 0); } else if !args.quiet { - eprintln!("timeout: {}", e); + eprintln!("{}: {}", prog_name, e); } return e.exit_code(); } if args.verbose && !args.quiet { - eprintln!("timeout: file '{}' found, starting command", path); + eprintln!("{}: file '{}' found, starting command", prog_name, path); } } @@ -276,6 +302,7 @@ fn run_main() -> u8 { config.retry_count, &config.limits, config.cpu_throttle, + config.confine, ); } @@ -285,7 +312,7 @@ fn run_main() -> u8 { if args.json { print_json_error(&e, elapsed_ms); } else if !args.quiet { - eprintln!("timeout: {}", e); + eprintln!("{}: {}", prog_name, e); } e.exit_code() } @@ -300,20 +327,29 @@ fn run_main() -> u8 { * SIGKILL during the write could still produce partial output. CI systems * parsing this output should validate JSON before processing. */ +#[allow(clippy::too_many_arguments)] fn print_json_output( result: &RunResult, elapsed_ms: u64, exit_code: u8, attempts: &[AttemptResult], retry_count: u32, - limits: &darwin_timeout::ResourceLimits, - cpu_throttle: Option, + limits: &procguard::ResourceLimits, + cpu_throttle: Option, + confine: Confine, ) { - /* Schema version 7: memory limit exceeded status */ - const SCHEMA_VERSION: u8 = 7; + /* Schema version 8: added clock field for time measurement mode */ + const SCHEMA_VERSION: u8 = 8; + + /* convert Confine to JSON string */ + let clock_str = match confine { + Confine::Wall => "wall", + Confine::Active => "active", + _ => "unknown", /* future-proof for #[non_exhaustive] */ + }; /* helper to append rusage fields to JSON string */ - fn append_rusage(json: &mut String, rusage: Option<&darwin_timeout::process::ResourceUsage>) { + fn append_rusage(json: &mut String, rusage: Option<&procguard::process::ResourceUsage>) { if let Some(r) = rusage { let _ = write!( json, @@ -352,8 +388,8 @@ fn print_json_output( /* helper to append resource limits metadata if configured */ fn append_limits( json: &mut String, - limits: &darwin_timeout::ResourceLimits, - cpu_throttle: Option, + limits: &procguard::ResourceLimits, + cpu_throttle: Option, ) { if limits.mem_bytes.is_none() && limits.cpu_time.is_none() && cpu_throttle.is_none() { return; @@ -393,8 +429,8 @@ fn print_json_output( let mut json = String::with_capacity(256); let _ = write!( json, - r#"{{"schema_version":{},"status":"completed","exit_code":{},"elapsed_ms":{}"#, - SCHEMA_VERSION, code, elapsed_ms + r#"{{"schema_version":{},"status":"completed","clock":"{}","exit_code":{},"elapsed_ms":{}"#, + SCHEMA_VERSION, clock_str, code, elapsed_ms ); append_rusage(&mut json, Some(rusage)); append_attempts(&mut json, attempts, retry_count); @@ -410,12 +446,12 @@ fn print_json_output( hook, reason, } => { - let sig_num = darwin_timeout::signal::signal_number(*signal); + let sig_num = procguard::signal::signal_number(*signal); let status_code = status.and_then(|s| s.code()).unwrap_or(-1); - let sig_name = darwin_timeout::signal::signal_name(*signal); + let sig_name = procguard::signal::signal_name(*signal); let reason_str = match reason { - darwin_timeout::runner::TimeoutReason::WallClock => "wall_clock", - darwin_timeout::runner::TimeoutReason::StdinIdle => "stdin_idle", + procguard::runner::TimeoutReason::WallClock => "wall_clock", + procguard::runner::TimeoutReason::StdinIdle => "stdin_idle", _ => "unknown", /* future-proof for #[non_exhaustive] */ }; @@ -423,8 +459,9 @@ fn print_json_output( let mut json = String::with_capacity(512); let _ = write!( json, - r#"{{"schema_version":{},"status":"timeout","timeout_reason":"{}","signal":"{}","signal_num":{},"killed":{},"command_exit_code":{},"exit_code":{},"elapsed_ms":{}"#, + r#"{{"schema_version":{},"status":"timeout","clock":"{}","timeout_reason":"{}","signal":"{}","signal_num":{},"killed":{},"command_exit_code":{},"exit_code":{},"elapsed_ms":{}"#, SCHEMA_VERSION, + clock_str, reason_str, sig_name, sig_num, @@ -470,15 +507,16 @@ fn print_json_output( limit_bytes, actual_bytes, } => { - let sig_num = darwin_timeout::signal::signal_number(*signal); + let sig_num = procguard::signal::signal_number(*signal); let status_code = status.and_then(|s| s.code()).unwrap_or(-1); - let sig_name = darwin_timeout::signal::signal_name(*signal); + let sig_name = procguard::signal::signal_name(*signal); let mut json = String::with_capacity(512); let _ = write!( json, - r#"{{"schema_version":{},"status":"memory_limit","signal":"{}","signal_num":{},"killed":{},"command_exit_code":{},"exit_code":{},"elapsed_ms":{},"limit_bytes":{},"actual_bytes":{}"#, + r#"{{"schema_version":{},"status":"memory_limit","clock":"{}","signal":"{}","signal_num":{},"killed":{},"command_exit_code":{},"exit_code":{},"elapsed_ms":{},"limit_bytes":{},"actual_bytes":{}"#, SCHEMA_VERSION, + clock_str, sig_name, sig_num, killed, @@ -500,14 +538,15 @@ fn print_json_output( status, rusage, } => { - let sig_num = darwin_timeout::signal::signal_number(*signal); + let sig_num = procguard::signal::signal_number(*signal); let status_code = status.and_then(|s| s.code()).unwrap_or(-1); let mut json = String::with_capacity(256); let _ = write!( json, - r#"{{"schema_version":{},"status":"signal_forwarded","signal":"{}","signal_num":{},"command_exit_code":{},"exit_code":{},"elapsed_ms":{}"#, + r#"{{"schema_version":{},"status":"signal_forwarded","clock":"{}","signal":"{}","signal_num":{},"command_exit_code":{},"exit_code":{},"elapsed_ms":{}"#, SCHEMA_VERSION, - darwin_timeout::signal::signal_name(*signal), + clock_str, + procguard::signal::signal_name(*signal), sig_num, status_code, exit_code, @@ -524,8 +563,8 @@ fn print_json_output( let mut json = String::with_capacity(128); let _ = write!( json, - r#"{{"schema_version":{},"status":"unknown","exit_code":{},"elapsed_ms":{}"#, - SCHEMA_VERSION, exit_code, elapsed_ms + r#"{{"schema_version":{},"status":"unknown","clock":"{}","exit_code":{},"elapsed_ms":{}"#, + SCHEMA_VERSION, clock_str, exit_code, elapsed_ms ); append_attempts(&mut json, attempts, retry_count); append_limits(&mut json, limits, cpu_throttle); @@ -535,8 +574,8 @@ fn print_json_output( } } -fn print_json_error(err: &darwin_timeout::error::TimeoutError, elapsed_ms: u64) { - const SCHEMA_VERSION: u8 = 7; +fn print_json_error(err: &procguard::error::TimeoutError, elapsed_ms: u64) { + const SCHEMA_VERSION: u8 = 8; let exit_code = err.exit_code(); /* Escape control characters for valid JSON */ diff --git a/src/runner.rs b/src/runner.rs index b1f36a6..987f0a3 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -568,7 +568,7 @@ fn status_to_exit_code(status: &RawExitStatus) -> u8 { /// # Example /// /// ```ignore -/// use darwin_timeout::{RunConfig, Signal}; +/// use procguard::{RunConfig, Signal}; /// use std::time::Duration; /// /// let config = RunConfig { diff --git a/src/signal.rs b/src/signal.rs index cc5c3ee..aebc32e 100644 --- a/src/signal.rs +++ b/src/signal.rs @@ -98,7 +98,7 @@ impl Signal { /// # Examples /// /// ``` -/// use darwin_timeout::signal::{parse_signal, Signal}; +/// use procguard::signal::{parse_signal, Signal}; /// /// assert_eq!(parse_signal("TERM").unwrap(), Signal::SIGTERM); /// assert_eq!(parse_signal("SIGTERM").unwrap(), Signal::SIGTERM); diff --git a/src/wait.rs b/src/wait.rs index b3d7850..f80f845 100644 --- a/src/wait.rs +++ b/src/wait.rs @@ -394,7 +394,7 @@ mod tests { #[test] #[cfg_attr(miri, ignore)] // Miri doesn't support mach_continuous_time fn test_wait_for_file_created_during_wait() { - let test_file = "/tmp/darwin_timeout_test_wait_file"; + let test_file = "/tmp/procguard_test_wait_file"; // Clean up any leftover file let _ = fs::remove_file(test_file); diff --git a/tests/benchmarks.rs b/tests/benchmarks.rs index 14cf293..770ecbf 100644 --- a/tests/benchmarks.rs +++ b/tests/benchmarks.rs @@ -22,7 +22,15 @@ use assert_cmd::Command; use std::time::{Duration, Instant}; -#[allow(deprecated)] +/* get the timeout binary path as a string */ +#[allow(dead_code, deprecated)] /* cargo_bin deprecated but cargo_bin! requires nightly */ +fn timeout_bin_path() -> String { + assert_cmd::cargo::cargo_bin("timeout") + .to_string_lossy() + .into_owned() +} + +#[allow(deprecated)] /* cargo_bin deprecated but cargo_bin! requires nightly */ fn timeout_cmd() -> Command { Command::cargo_bin("timeout").unwrap() } @@ -32,7 +40,7 @@ fn timeout_cmd() -> Command { * ========================================================================= */ #[test] -fn bench_startup_overhead() { +fn test_startup_overhead() { /* * Run 'true' (does nothing, exits immediately) through timeout. * This measures our startup + teardown overhead. @@ -894,7 +902,7 @@ fn bench_stdin_timeout_triggers() { let max_allowed = Duration::from_millis(500); let start = Instant::now(); - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_timeout")) + let mut child = std::process::Command::new(timeout_bin_path().as_str()) .args(["--stdin-timeout", "100ms", "60s", "sleep", "60"]) .stdin(Stdio::piped()) .spawn() diff --git a/tests/integration.rs b/tests/integration.rs index 708bff3..21008da 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,30 +1,45 @@ /* - * Integration tests for the timeout CLI. + * Integration tests for the procguard CLI. * * These tests validate GNU coreutils compatibility - we must behave exactly * like Linux timeout for scripts to be portable. Each test documents the * expected behavior with references to GNU behavior where relevant. + * + * procguard provides both: + * - `procguard`: primary binary (wall-clock default) + * - `timeout`: GNU-compatible alias (active-time default via argv[0] detection) */ use assert_cmd::Command; use predicates::prelude::*; use std::time::{Duration, Instant}; -#[allow(deprecated)] +/* get the timeout binary path as a string */ +#[allow(deprecated)] /* cargo_bin deprecated but cargo_bin! requires nightly */ +fn timeout_bin_path() -> String { + assert_cmd::cargo::cargo_bin("timeout") + .to_string_lossy() + .into_owned() +} + +/* timeout alias - tests mostly use this for GNU compatibility */ +#[allow(deprecated)] /* cargo_bin deprecated but cargo_bin! requires nightly */ fn timeout_cmd() -> Command { Command::cargo_bin("timeout").unwrap() } +/* procguard primary binary */ +#[allow(dead_code, deprecated)] /* cargo_bin deprecated but cargo_bin! requires nightly */ +fn procguard_cmd() -> Command { + Command::cargo_bin("procguard").unwrap() +} + /* ========================================================================= * BASIC FUNCTIONALITY - Core timeout behavior * ========================================================================= */ #[test] -fn test_command_completes_before_timeout() { - /* - * When command finishes before timeout, we should exit immediately - * with the command's exit status. No waiting around. - */ +fn test_basic_timeout() { let start = Instant::now(); timeout_cmd() @@ -594,11 +609,15 @@ fn test_help() { #[test] fn test_version() { + /* + * Version output shows "procguard" regardless of which binary is invoked. + * Both timeout and procguard are the same binary, just different entry names. + */ timeout_cmd() .arg("--version") .assert() .success() - .stdout(predicate::str::contains("timeout")); + .stdout(predicate::str::contains("procguard")); } #[test] @@ -816,7 +835,7 @@ fn test_stdin_passthrough_times_out_on_idle() { use std::process::{Command, Stdio}; use std::thread; - let mut child = Command::new(env!("CARGO_BIN_EXE_timeout")) + let mut child = Command::new(timeout_bin_path().as_str()) .args([ "--stdin-timeout", "0.2s", @@ -855,7 +874,7 @@ fn test_stdin_passthrough_eof_no_false_timeout() { use std::io::Write; use std::process::{Command, Stdio}; - let mut child = Command::new(env!("CARGO_BIN_EXE_timeout")) + let mut child = Command::new(timeout_bin_path().as_str()) .args([ "--stdin-timeout", "0.2s", @@ -1122,7 +1141,7 @@ fn test_signal_forwarding_sigterm() { use std::thread; /* Start timeout with a long-running command */ - let mut timeout_process = Command::new(env!("CARGO_BIN_EXE_timeout")) + let mut timeout_process = Command::new(timeout_bin_path().as_str()) .args(["60s", "sleep", "60"]) .stdin(Stdio::null()) .stdout(Stdio::null()) @@ -1172,7 +1191,7 @@ fn test_signal_forwarding_sigint() { use std::process::{Command, Stdio}; use std::thread; - let mut timeout_process = Command::new(env!("CARGO_BIN_EXE_timeout")) + let mut timeout_process = Command::new(timeout_bin_path().as_str()) .args(["60s", "sleep", "60"]) .stdin(Stdio::null()) .stdout(Stdio::null()) @@ -1609,28 +1628,28 @@ fn test_quiet_verbose_conflict() { #[test] fn test_json_schema_version() { /* - * All JSON output should include schema_version field (version 7 with memory limit) + * All JSON output should include schema_version field (version 8 with memory limit) */ /* Test completed */ timeout_cmd() .args(["--json", "5s", "true"]) .assert() .success() - .stdout(predicate::str::contains(r#""schema_version":7"#)); + .stdout(predicate::str::contains(r#""schema_version":8"#)); /* Test timeout */ timeout_cmd() .args(["--json", "0.1s", "sleep", "10"]) .assert() .code(124) - .stdout(predicate::str::contains(r#""schema_version":7"#)); + .stdout(predicate::str::contains(r#""schema_version":8"#)); /* Test error */ timeout_cmd() .args(["--json", "5s", "nonexistent_command_xyz_12345"]) .assert() .code(127) - .stdout(predicate::str::contains(r#""schema_version":7"#)); + .stdout(predicate::str::contains(r#""schema_version":8"#)); } #[test] @@ -1824,7 +1843,7 @@ fn test_signal_forwarding_reports_correct_signal() { use std::process::{Command, Stdio}; use std::thread; - let timeout_process = Command::new(env!("CARGO_BIN_EXE_timeout")) + let timeout_process = Command::new(timeout_bin_path().as_str()) .args(["-v", "30s", "sleep", "100"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -1860,7 +1879,7 @@ fn test_signal_forwarded_json_rusage() { use std::process::{Command, Stdio}; use std::thread; - let timeout_process = Command::new(env!("CARGO_BIN_EXE_timeout")) + let timeout_process = Command::new(timeout_bin_path().as_str()) .args(["--json", "30s", "sleep", "100"]) .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -1957,7 +1976,7 @@ fn test_wait_for_file_created_during_wait() { use std::fs; use std::thread; - let test_file = "/tmp/darwin_timeout_test_wait_file_integration"; + let test_file = "/tmp/procguard_test_wait_file_integration"; let _ = fs::remove_file(test_file); /* Spawn a thread to create the file after a delay */ @@ -2567,7 +2586,7 @@ fn test_stdin_timeout_triggers() { * We must take() the stdin handle to prevent EOF when wait() is called. */ use std::process::Stdio; - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_timeout")) + let mut child = std::process::Command::new(timeout_bin_path().as_str()) .args(["--stdin-timeout", "200ms", "60s", "sleep", "60"]) .stdin(Stdio::piped()) .spawn() @@ -2585,7 +2604,7 @@ fn test_stdin_timeout_short_flag() { * -S short flag should work like --stdin-timeout */ use std::process::Stdio; - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_timeout")) + let mut child = std::process::Command::new(timeout_bin_path().as_str()) .args(["-S", "200ms", "60s", "sleep", "60"]) .stdin(Stdio::piped()) .spawn() @@ -2602,7 +2621,7 @@ fn test_stdin_timeout_short_flag_embedded() { * -S200ms embedded value should work */ use std::process::Stdio; - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_timeout")) + let mut child = std::process::Command::new(timeout_bin_path().as_str()) .args(["-S200ms", "60s", "sleep", "60"]) .stdin(Stdio::piped()) .spawn() @@ -2634,7 +2653,7 @@ fn test_stdin_timeout_env_var() { * TIMEOUT_STDIN_TIMEOUT env var should set default stdin timeout */ use std::process::Stdio; - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_timeout")) + let mut child = std::process::Command::new(timeout_bin_path().as_str()) .env("TIMEOUT_STDIN_TIMEOUT", "200ms") .args(["60s", "sleep", "60"]) .stdin(Stdio::piped()) @@ -2658,7 +2677,7 @@ fn test_stdin_timeout_cli_overrides_env() { use std::process::Stdio; /* env var would trigger quickly (50ms), but CLI sets longer timeout (60s) */ /* so wall clock timeout (200ms) fires first */ - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_timeout")) + let mut child = std::process::Command::new(timeout_bin_path().as_str()) .env("TIMEOUT_STDIN_TIMEOUT", "50ms") .args(["--stdin-timeout", "60s", "200ms", "sleep", "60"]) .stdin(Stdio::piped()) @@ -2678,7 +2697,7 @@ fn test_stdin_timeout_json_reason_stdin_idle() { */ use std::io::Read; use std::process::Stdio; - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_timeout")) + let mut child = std::process::Command::new(timeout_bin_path().as_str()) .args(["--json", "--stdin-timeout", "100ms", "60s", "sleep", "60"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -2734,7 +2753,7 @@ fn test_stdin_timeout_verbose() { * Verbose should show stdin idle message */ use std::process::Stdio; - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_timeout")) + let mut child = std::process::Command::new(timeout_bin_path().as_str()) .args([ "--verbose", "--stdin-timeout", @@ -2771,7 +2790,7 @@ fn test_stdin_timeout_quiet() { * Quiet mode should suppress stdin timeout messages */ use std::process::Stdio; - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_timeout")) + let mut child = std::process::Command::new(timeout_bin_path().as_str()) .args(["--quiet", "--stdin-timeout", "100ms", "60s", "sleep", "60"]) .stdin(Stdio::piped()) .stderr(Stdio::piped()) @@ -2803,7 +2822,7 @@ fn test_stdin_timeout_with_wall_timeout() { */ use std::process::Stdio; let start = std::time::Instant::now(); - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_timeout")) + let mut child = std::process::Command::new(timeout_bin_path().as_str()) .args(["--stdin-timeout", "100ms", "60s", "sleep", "60"]) .stdin(Stdio::piped()) .spawn() @@ -2829,7 +2848,7 @@ fn test_stdin_timeout_wall_timeout_fires_first() { */ use std::io::Read; use std::process::Stdio; - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_timeout")) + let mut child = std::process::Command::new(timeout_bin_path().as_str()) .args(["--json", "--stdin-timeout", "60s", "100ms", "sleep", "60"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -2858,7 +2877,7 @@ fn test_stdin_timeout_with_heartbeat() { */ use std::io::Read; use std::process::Stdio; - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_timeout")) + let mut child = std::process::Command::new(timeout_bin_path().as_str()) .args([ "--heartbeat", "50ms", @@ -2895,7 +2914,7 @@ fn test_stdin_timeout_combined_flags() { */ use std::io::Read; use std::process::Stdio; - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_timeout")) + let mut child = std::process::Command::new(timeout_bin_path().as_str()) .args(["-v", "-S", "100ms", "60s", "sleep", "60"]) .stdin(Stdio::piped()) .stderr(Stdio::piped()) @@ -2930,7 +2949,7 @@ fn test_stdin_timeout_dev_null_no_busy_loop() { let dev_null = File::open("/dev/null").expect("failed to open /dev/null"); let start = std::time::Instant::now(); - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_timeout")) + let mut child = std::process::Command::new(timeout_bin_path().as_str()) .args(["--stdin-timeout", "50ms", "200ms", "sleep", "60"]) .stdin(dev_null) .spawn() @@ -2966,7 +2985,7 @@ fn test_stdin_timeout_null_stdin_no_cpu_spike() { let start = std::time::Instant::now(); - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_timeout")) + let mut child = std::process::Command::new(timeout_bin_path().as_str()) .args(["--stdin-timeout", "50ms", "200ms", "sleep", "60"]) .stdin(Stdio::null()) .spawn() @@ -2998,7 +3017,7 @@ fn test_stdin_timeout_closed_stdin_graceful() { "-c", &format!( "{} --stdin-timeout 50ms 200ms sleep 60 0<&-", - env!("CARGO_BIN_EXE_timeout") + timeout_bin_path().as_str() ), ]) .output() @@ -3025,7 +3044,7 @@ fn test_stdin_timeout_json_with_dev_null() { let dev_null = File::open("/dev/null").expect("failed to open /dev/null"); - let output = std::process::Command::new(env!("CARGO_BIN_EXE_timeout")) + let output = std::process::Command::new(timeout_bin_path().as_str()) .args(["--json", "--stdin-timeout", "50ms", "100ms", "sleep", "60"]) .stdin(dev_null) .output() @@ -3051,7 +3070,7 @@ fn test_stdin_timeout_with_retry() { use std::process::Stdio; let start = std::time::Instant::now(); - let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_timeout")) + let mut child = std::process::Command::new(timeout_bin_path().as_str()) .args([ "--json", "--stdin-timeout", @@ -3107,7 +3126,7 @@ fn test_stdin_timeout_retry_with_dev_null() { let dev_null = File::open("/dev/null").expect("failed to open /dev/null"); let start = std::time::Instant::now(); - let output = std::process::Command::new(env!("CARGO_BIN_EXE_timeout")) + let output = std::process::Command::new(timeout_bin_path().as_str()) .args([ "--json", "--stdin-timeout", @@ -3314,8 +3333,8 @@ fn test_mem_limit_kills_on_exceed() { let stdout = String::from_utf8_lossy(&output.stdout); assert!( - stdout.contains(r#""schema_version":7"#), - "expected schema_version 7: {}", + stdout.contains(r#""schema_version":8"#), + "expected schema_version 8: {}", stdout ); assert!( @@ -3406,3 +3425,117 @@ fn test_cpu_time_kills_cpu_intensive() { String::from_utf8_lossy(&output.stdout) ); } + +/* ========================================================================= + * DUAL BINARY BEHAVIOR - procguard vs timeout alias + * ========================================================================= */ + +#[test] +fn test_procguard_defaults_to_wall_clock() { + /* + * procguard binary should default to --confine wall (sleep-aware). + * This is the unique darwin feature - timeout survives system sleep. + */ + let output = procguard_cmd() + .args(["--json", "5s", "echo", "test"]) + .output() + .expect("procguard should run"); + + let stdout = String::from_utf8_lossy(&output.stdout); + /* wall clock mode is indicated in JSON output */ + assert!( + stdout.contains("\"clock\":\"wall\"") || stdout.contains("\"status\":\"completed\""), + "procguard should use wall clock by default: {}", + stdout + ); +} + +#[test] +fn test_timeout_alias_defaults_to_active() { + /* + * timeout alias should default to --confine active for GNU compatibility. + * This matches the behavior of GNU timeout which uses active/monotonic time. + */ + let output = timeout_cmd() + .args(["--json", "5s", "true"]) + .output() + .expect("timeout should run"); + + let stdout = String::from_utf8_lossy(&output.stdout); + /* active clock mode is indicated in JSON output */ + assert!( + stdout.contains("\"clock\":\"active\""), + "timeout alias should use active clock by default: {}", + stdout + ); +} + +#[test] +fn test_timeout_alias_respects_explicit_confine_wall() { + /* + * Even when invoked as 'timeout', explicit --confine wall should override. + */ + let output = timeout_cmd() + .args(["--json", "--confine", "wall", "5s", "true"]) + .output() + .expect("timeout should run"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("\"clock\":\"wall\""), + "explicit --confine wall should override timeout default: {}", + stdout + ); +} + +#[test] +fn test_procguard_respects_explicit_confine_active() { + /* + * Even when invoked as 'procguard', explicit --confine active should override. + */ + let output = procguard_cmd() + .args(["--json", "--confine", "active", "5s", "true"]) + .output() + .expect("procguard should run"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("\"clock\":\"active\""), + "explicit --confine active should work on procguard: {}", + stdout + ); +} + +#[test] +fn test_procguard_defaults_to_wall() { + /* + * procguard should default to --confine wall (wall clock). + * This is different from the timeout alias which defaults to active. + */ + let output = procguard_cmd() + .args(["--json", "5s", "true"]) + .output() + .expect("procguard should run"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("\"clock\":\"wall\""), + "procguard should use wall clock by default: {}", + stdout + ); +} + +#[test] +fn test_procguard_version_shows_procguard() { + /* + * Version output should show "procguard" as the program name. + */ + procguard_cmd() + .arg("--version") + .assert() + .success() + .stdout(predicate::str::contains("procguard")) + .stdout(predicate::str::contains( + "formally verified process supervisor", + )); +} diff --git a/tests/library_api.rs b/tests/library_api.rs index caa7755..99701a5 100644 --- a/tests/library_api.rs +++ b/tests/library_api.rs @@ -1,20 +1,20 @@ /* * library_api.rs * - * integration-style tests exercising darwin-timeout as a library. + * integration-style tests exercising procguard as a library. * * goal: ensure the public API is usable without shelling out to the CLI. */ use std::time::Duration; -use darwin_timeout::error::exit_codes; -use darwin_timeout::runner::{ +use procguard::error::exit_codes; +use procguard::runner::{ RunConfig, RunResult, cleanup_signal_forwarding, run_command, run_with_retry, setup_signal_forwarding, }; -use darwin_timeout::signal::Signal; -use darwin_timeout::{TimeoutReason, parse_duration, parse_signal}; +use procguard::signal::Signal; +use procguard::{TimeoutReason, parse_duration, parse_signal}; fn basic_config(timeout: Duration) -> RunConfig { RunConfig { diff --git a/tests/proptest.rs b/tests/proptest.rs index 47f427b..d06999b 100644 --- a/tests/proptest.rs +++ b/tests/proptest.rs @@ -8,9 +8,9 @@ use proptest::prelude::*; use std::time::Duration; -use darwin_timeout::duration::parse_duration; -use darwin_timeout::rlimit::parse_mem_limit; -use darwin_timeout::signal::{Signal, parse_signal, signal_name}; +use procguard::duration::parse_duration; +use procguard::rlimit::parse_mem_limit; +use procguard::signal::{Signal, parse_signal, signal_name}; /* ============================================================================ * Duration Parsing Properties