From 950d7a340879cd6bd98daca936faef49cf290cbe Mon Sep 17 00:00:00 2001 From: denispol <19826856+denispol@users.noreply.github.com> Date: Tue, 6 Jan 2026 09:40:14 +0100 Subject: [PATCH 1/7] chore: rename darwin-timeout to procguard Complete rebrand from darwin-timeout to procguard: Core Changes: - Rename package to procguard in Cargo.toml - Add dual-binary support: procguard (primary) + timeout (alias) - Add argv[0] detection for timeout backward compatibility - Timeout alias defaults to --confine active (GNU compatibility) - Bump JSON schema to v8 (adds clock field) Files Modified (~40 files): - src/*.rs: Update crate name references - tests/: Add 6 new dual-binary tests (185 total) - docs/: Update all documentation - completions/: Rename to procguard.* - fuzz/: Update crate references - scripts/: Update binary names - workflows/: Update binary references JSON Schema v8: - Added clock field showing wall or active - Allows users to verify which time mode was used Verification: - 154 unit tests passing - 185 integration tests passing - 10 library API tests passing - 30 proptest properties passing - Binary size: 119KB (under 150KB limit) --- .github/workflows/ci.yml | 2 +- .github/workflows/homebrew.yml | 2 +- .github/workflows/release.yml | 2 +- CONTRIBUTING.md | 12 +- Cargo.lock | 20 +- Cargo.toml | 18 +- README.md | 378 +++++++------------ build.rs | 2 +- completions/{timeout.bash => procguard.bash} | 8 +- completions/procguard.fish | 61 +++ completions/{timeout.zsh => procguard.zsh} | 9 +- completions/timeout.fish | 39 -- docs/VERIFICATION.md | 8 +- docs/benchmarks/RESULTS.md | 12 +- docs/benchmarks/precision_1s.json | 2 +- docs/benchmarks/run1_startup.json | 2 +- docs/benchmarks/run2_startup.json | 2 +- docs/benchmarks/run3_startup.json | 2 +- docs/benchmarks/run4_startup.json | 2 +- docs/benchmarks/run5_startup.json | 2 +- docs/json-output.md | 180 ++++----- docs/resource-limits.md | 4 +- fuzz/Cargo.lock | 113 ------ fuzz/Cargo.toml | 4 +- fuzz/fuzz_targets/parse_args.rs | 2 +- fuzz/fuzz_targets/parse_duration.rs | 2 +- fuzz/fuzz_targets/parse_mem_limit.rs | 2 +- fuzz/fuzz_targets/parse_signal.rs | 2 +- scripts/benchmark.sh | 10 +- scripts/pre-commit | 2 +- scripts/pre-push | 2 +- scripts/setup-hooks.sh | 2 +- scripts/verify-all.sh | 2 +- src/args.rs | 143 ++++--- src/duration.rs | 2 +- src/lib.rs | 10 +- src/main.rs | 125 +++--- src/runner.rs | 2 +- src/signal.rs | 2 +- src/wait.rs | 2 +- tests/integration.rs | 147 +++++++- tests/library_api.rs | 10 +- tests/proptest.rs | 6 +- 43 files changed, 693 insertions(+), 668 deletions(-) rename completions/{timeout.bash => procguard.bash} (93%) create mode 100644 completions/procguard.fish rename completions/{timeout.zsh => procguard.zsh} (94%) delete mode 100644 completions/timeout.fish delete mode 100644 fuzz/Cargo.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d214ba..91ad61f 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. diff --git a/.github/workflows/homebrew.yml b/.github/workflows/homebrew.yml index ea3aabf..148c7e5 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 }}/timeout-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..5a0da03 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. 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..4af0d1f 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.4.0" +dependencies = [ + "assert_cmd", + "libc", + "predicates", + "proptest", +] + [[package]] name = "proptest" version = "1.9.0" diff --git a/Cargo.toml b/Cargo.toml index d935ee1..f8a7b95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,15 @@ [package] -name = "darwin-timeout" +name = "procguard" version = "1.4.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,12 @@ opt-level = 3 debug-assertions = true overflow-checks = true +# Primary binary - wall-clock default +[[bin]] +name = "procguard" +path = "src/main.rs" + +# GNU-compatible alias - active-time default (via argv[0] detection) [[bin]] name = "timeout" path = "src/main.rs" diff --git a/README.md b/README.md index 9161a46..1152fb6 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,47 @@ -darwin-timeout -============== +# procguard -GNU `timeout` for macOS, done right. Works through sleep. ~100KB. Zero dependencies. +The formally verified process supervisor for macOS. -**CLI tool** — drop-in replacement for GNU timeout. -**Rust library** — embed timeout logic in your own tools. +**CLI tool** — process timeouts, resource limits, lifecycle control. +**Rust library** — embed supervision logic in your own tools. - brew install denispol/tap/darwin-timeout # CLI - cargo add darwin-timeout # library + brew install denispol/tap/procguard # CLI + cargo add procguard # library -Works exactly like GNU timeout: +Works exactly like GNU timeout (it's a drop-in replacement): - 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 + procguard 30s ./slow-command # kill after 30 seconds + procguard -k 5 1m ./stubborn # SIGTERM, then SIGKILL after 5s + procguard --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 + procguard --json 5m ./test-suite # JSON output for CI + procguard --on-timeout 'cleanup.sh' 30s ./task # pre-timeout hook + procguard --retry 3 30s ./flaky-test # retry on timeout + procguard --mem-limit 1G 1h ./build # kill if memory exceeds 1GB + procguard --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. +## GNU Compatibility -Why? ----- +**Dual binary:** `procguard` is the primary binary. A `timeout` alias is also provided for scripts expecting GNU timeout. + +| Binary | Default Behavior | Use Case | +| ----------- | --------------------------------- | ------------------------- | +| `procguard` | Wall clock (survives sleep) | macOS-native, sleep-aware | +| `timeout` | Active time (pauses during sleep) | GNU-compatible scripts | + +```bash +# These behave identically to GNU timeout: +timeout 30s ./command +timeout -k 5 1m ./stubborn + +# procguard defaults to wall-clock (unique to macOS): +procguard 30s ./command # survives system sleep +procguard -c active 30s ./command # GNU-like behavior +``` + +## Why procguard? Apple doesn't ship `timeout`. The alternatives have problems: @@ -40,9 +54,9 @@ Apple doesn't ship `timeout`. The alternatives have problems: - 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. +procguard 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 +**Scenario:** `procguard 1h ./build` with laptop sleeping 45min in the middle 0 15min 1h 1h 45min ├──────────┬──────────────────────────────┬──────────────────────────────┤ @@ -50,7 +64,7 @@ darwin-timeout uses `mach_continuous_time`, the only macOS clock that keeps tick │ awake │ sleep │ awake │ └──────────┴──────────────────────────────┴──────────────────────────────┘ - darwin-timeout |██████████|██████████████████████████████^ fires at 1h ✓ + procguard |██████████|██████████████████████████████^ fires at 1h ✓ (counts sleep time) GNU timeout |██████████|······························|██████████████████████████████^ fires at 1h 45min ✗ @@ -58,42 +72,43 @@ darwin-timeout uses `mach_continuous_time`, the only macOS clock that keeps tick 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.* +| | procguard | GNU coreutils | +| ------------------------- | ------------------ | --------------- | +| Works during system sleep | ✓ | ✗ | +| Selectable time mode | ✓ (wall/active) | ✗ (active only) | +| **Resource limits** | ✓ (mem/CPU) | ✗ | +| **Formal verification** | ✓ (19 kani proofs) | ✗ | +| 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 ------------------ +## Quality & Verification -darwin-timeout uses a **five-layer verification approach**: +procguard 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 | +| Method | Coverage | What It Catches | +| ------------------------------ | -------------------------- | ------------------------------------------------- | +| **Unit tests** | 154 tests | Logic errors, edge cases | +| **Integration tests** | 184 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) @@ -101,108 +116,76 @@ darwin-timeout uses a **five-layer verification approach**: See [docs/VERIFICATION.md](docs/VERIFICATION.md) for methodology details. -Install -------- +## Install **Homebrew** (recommended): - brew install denispol/tap/darwin-timeout + brew install denispol/tap/procguard -**Binary download:** Grab the universal binary (arm64 + x86_64) from [releases](https://github.com/denispol/darwin-timeout/releases). +**Binary download:** Grab the universal binary (arm64 + x86_64) from [releases](https://github.com/denispol/procguard/releases). **From source (CLI):** cargo build --release - sudo cp target/release/timeout /usr/local/bin/ + sudo cp target/release/procguard /usr/local/bin/ + sudo ln -s procguard /usr/local/bin/timeout # optional: GNU-compatible alias **As a Rust library:** - cargo add darwin-timeout + cargo add procguard Shell completions are installed automatically with Homebrew. For manual install, see [completions/](completions/). -Quick Start ------------ +## 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 + procguard 30 ./slow-command # kill after 30 seconds + procguard -k 5 30 ./stubborn # SIGTERM, then SIGKILL after 5s + procguard --json 1m ./build # JSON output for CI + procguard -v 10 ./script # verbose: shows signals sent -Use Cases ---------- +## Use Cases **CI/CD**: Stop flaky tests before they hang your pipeline. - timeout --json 5m ./run-tests + procguard --json 5m ./run-tests **Overnight builds**: Timeouts that work even when your Mac sleeps. - timeout 2h make release # 2 hours wall-clock, guaranteed + procguard 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 + procguard 10s curl https://api.example.com/health **Script safety**: Ensure cleanup scripts actually finish. - timeout -k 10s 60s ./cleanup.sh + procguard -k 10s 60s ./cleanup.sh **Coordinated startup**: Wait for dependencies before running. - timeout --wait-for-file /tmp/db-ready 5m ./migrate + procguard --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. +**Prompt detection**: Kill commands that unexpectedly prompt for input. - timeout --stdin-timeout 5s ./test-suite # fail if it prompts for input + procguard --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. -**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 | \ + pg_dump mydb | procguard -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 +**CI keep-alive**: Prevent CI systems from killing long jobs. - # Multi-core cap: allow up to 4 cores (400%) for parallel builds - timeout --cpu-percent 400 --mem-limit 8G 1h cargo build --release -j8 + procguard --heartbeat 60s 2h ./integration-tests - # Full resource box: time + memory + CPU limits together - timeout --mem-limit 512M --cpu-percent 25 --cpu-time 5m 30m ./untrusted-script +**Resource sandboxing**: Enforce memory and CPU limits. -> **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. + procguard --mem-limit 4G 2h make -j8 + procguard --cpu-percent 50 1h ./batch-process -Options -------- +## Options - timeout [OPTIONS] DURATION COMMAND [ARGS...] + procguard [OPTIONS] DURATION COMMAND [ARGS...] **GNU-compatible flags:** @@ -212,7 +195,7 @@ Options -p, --preserve-status exit with command's status, not 124 -f, --foreground don't create process group -**darwin-timeout extensions:** +**procguard extensions:** -q, --quiet suppress error messages -c, --confine MODE time mode: 'wall' (default) or 'active' @@ -226,172 +209,69 @@ Options -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) + -S, --stdin-timeout T kill command if stdin is idle for T --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) + --cpu-percent PCT throttle CPU to PCT% via SIGSTOP/SIGCONT **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 + 124 timed out (custom via --timeout-exit-code) + 125 procguard itself failed 126 command found but not executable 127 command not found 128+N command killed by signal N -Time Modes ----------- +## 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. +**wall** (default for `procguard`): Real elapsed time, including system sleep. - timeout 1h ./build - timeout -c wall 1h ./build # explicit + procguard 1h ./build # fires after 1 hour wall-clock -**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. +**active** (default for `timeout` alias): Only counts time when awake. Matches GNU behavior. - timeout -c active 1h ./benchmark # pauses during sleep, like GNU timeout + procguard -c active 1h ./benchmark # pauses during sleep + timeout 1h ./benchmark # same (timeout alias defaults to active) Under the hood: `wall` uses `mach_continuous_time`, `active` uses `CLOCK_MONOTONIC_RAW`. -Resource Limits ---------------- +## Resource Limits -Enforce memory and CPU constraints without containers or root privileges. Three complementary mechanisms: +Enforce memory and CPU constraints without containers or root privileges. **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). + procguard --mem-limit 2G 1h ./memory-hungry-app **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. + procguard --cpu-time 5m 1h ./compute-job **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. + procguard --cpu-percent 50 1h ./background-task -Environment Variables ---------------------- +See [docs/resource-limits.md](docs/resource-limits.md) for details. -Configure defaults without CLI flags: +## JSON Output - 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 +Machine-readable output for CI/CD pipelines: -Pre-timeout Hooks ------------------ + $ procguard --json 1s sleep 0.5 + {"schema_version":8,"status":"completed","exit_code":0,"elapsed_ms":504,...} -Run a command when timeout fires, before sending the signal: +See [docs/json-output.md](docs/json-output.md) for complete schema. - timeout --on-timeout 'echo "killing $p" >> /tmp/log' 5s ./long-task - timeout --on-timeout 'kill -QUIT %p' --on-timeout-limit 2s 30s ./server +## Library Usage -`%p` is replaced with the child PID. Hooks have their own timeout (default 5s). - -How It Works ------------- - -Built on Darwin kernel primitives: - -- **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 - -~100KB `no_std` binary. Custom allocator, direct syscalls, no libstd runtime. - -Benchmarks ----------- - -All benchmarks on Apple M4 Pro, macOS Tahoe 26.2, hyperfine 1.20.0. -See [docs/benchmarks/](docs/benchmarks/) for raw data and methodology. - - # Binary size - darwin-timeout: ~100KB - GNU coreutils: 15.7MB (157x larger) - - # Startup overhead (250 runs across 5 sessions) - darwin-timeout: 3.6ms ± 0.2ms - GNU timeout: 4.2ms ± 0.2ms (18% slower) - - # Timeout precision (20 runs, 1s timeout) - darwin-timeout: 1.014s ± 0.003s - GNU timeout: 1.017s ± 0.001s (identical) - - # CPU while waiting - darwin-timeout: 0.00 user, 0.00 sys (kqueue blocks) - - # 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) - -Development ------------ - - cargo test # run tests - cargo test --test proptest # property-based tests - cargo clippy # lint - ./scripts/verify-all.sh # full verification suite - -**Contributing:** See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow, verification requirements, and the testing pyramid. - -**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. - -Library Usage -------------- - -The `darwin_timeout` crate exposes the core timeout functionality for embedding in your own tools: +The `procguard` crate exposes the core timeout functionality for embedding in your own tools: ```rust -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; let _ = setup_signal_forwarding(); @@ -418,13 +298,21 @@ match run_command("sh", &args, &config) { **Platform:** macOS only (uses Darwin kernel APIs). -**API Docs:** [docs.rs/darwin-timeout](https://docs.rs/darwin-timeout) +**API Docs:** [docs.rs/procguard](https://docs.rs/procguard) + +> ⚠️ **Stability:** The library API is experimental. Use `..RunConfig::default()` when constructing configs. + +## Development + + cargo test # run tests + cargo test --test proptest # property-based tests + cargo clippy # lint + ./scripts/verify-all.sh # full verification suite -**Performance:** Library calls have the same performance as CLI invocations—both use identical code paths. No additional overhead. +**Contributing:** See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow. -> ⚠️ **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. +**Verification:** See [docs/VERIFICATION.md](docs/VERIFICATION.md) for testing methodology. -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/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/integration.rs b/tests/integration.rs index 708bff3..3de66bb 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -1,20 +1,31 @@ /* - * 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}; +/* timeout alias - tests mostly use this for GNU compatibility */ #[allow(deprecated)] fn timeout_cmd() -> Command { Command::cargo_bin("timeout").unwrap() } +/* procguard primary binary */ +#[allow(dead_code, deprecated)] +fn procguard_cmd() -> Command { + Command::cargo_bin("procguard").unwrap() +} + /* ========================================================================= * BASIC FUNCTIONALITY - Core timeout behavior * ========================================================================= */ @@ -594,11 +605,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] @@ -1609,28 +1624,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] @@ -1957,7 +1972,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 */ @@ -3314,8 +3329,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 +3421,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 From 1c21f2d4e698a1cdae7269653fdd69b921c2a5d5 Mon Sep 17 00:00:00 2001 From: denispol <19826856+denispol@users.noreply.github.com> Date: Sat, 31 Jan 2026 15:09:00 +0100 Subject: [PATCH 2/7] chore: update release infrastructure for procguard naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump version to 1.5.0 - Rename tarballs: timeout-macos-* → procguard-macos-* - Release now includes both procguard binary and timeout symlink - Update homebrew.yml to download procguard-macos-universal.tar.gz - Update build-universal.sh to build procguard with timeout symlink --- .github/workflows/homebrew.yml | 2 +- .github/workflows/release.yml | 73 ++++++++++++++++++---------------- Cargo.lock | 2 +- Cargo.toml | 2 +- scripts/build-universal.sh | 29 +++++++++----- 5 files changed, 60 insertions(+), 48 deletions(-) diff --git a/.github/workflows/homebrew.yml b/.github/workflows/homebrew.yml index 148c7e5..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/procguard/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 5a0da03..8e54412 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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/Cargo.lock b/Cargo.lock index 4af0d1f..4e91f4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -214,7 +214,7 @@ dependencies = [ [[package]] name = "procguard" -version = "1.4.0" +version = "1.5.0" dependencies = [ "assert_cmd", "libc", diff --git a/Cargo.toml b/Cargo.toml index f8a7b95..93f6926 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "procguard" -version = "1.4.0" +version = "1.5.0" edition = "2024" rust-version = "1.91" authors = ["denispol"] 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)" From 0ea744bc24661b5118a068bfaf5260c7a80e33a0 Mon Sep 17 00:00:00 2001 From: denispol <19826856+denispol@users.noreply.github.com> Date: Sat, 31 Jan 2026 15:36:38 +0100 Subject: [PATCH 3/7] fix: remove duplicate binary target warning - Build only `procguard` binary, create `timeout` symlink at runtime - Update test helpers to create symlink dynamically - Update CI to test both binaries via symlink - Fixes cargo warning: file found in multiple build targets This removes the warning while maintaining dual-binary functionality through argv[0] detection. The `timeout` symlink is created during: - Release packaging (release.yml, build-universal.sh) - Integration tests (tests/{integration,benchmarks}.rs) - CI verification (.github/workflows/ci.yml) --- .github/workflows/ci.yml | 11 +++-- Cargo.toml | 9 ++-- tests/benchmarks.rs | 36 ++++++++++++++-- tests/integration.rs | 88 ++++++++++++++++++++++++++-------------- 4 files changed, 101 insertions(+), 43 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91ad61f..b707a2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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/Cargo.toml b/Cargo.toml index 93f6926..405f03c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,15 +68,14 @@ debug-assertions = true overflow-checks = true # Primary binary - wall-clock default +# The `timeout` alias is created as a symlink during packaging/installation +# for GNU compatibility. Behavior is detected via argv[0] at runtime: +# - "procguard": defaults to --confine wall (sleep-aware) +# - "timeout": defaults to --confine active (GNU-compatible) [[bin]] name = "procguard" path = "src/main.rs" -# GNU-compatible alias - active-time default (via argv[0] detection) -[[bin]] -name = "timeout" -path = "src/main.rs" - [package.metadata.docs.rs] # docs.rs builds on linux by default; force building docs for macOS targets. targets = ["aarch64-apple-darwin", "x86_64-apple-darwin"] diff --git a/tests/benchmarks.rs b/tests/benchmarks.rs index 14cf293..6ca795e 100644 --- a/tests/benchmarks.rs +++ b/tests/benchmarks.rs @@ -22,9 +22,39 @@ use assert_cmd::Command; use std::time::{Duration, Instant}; +/* helper to get the procguard binary and create timeout symlink if needed */ +#[allow(deprecated)] /* cargo_bin! macro requires nightly, we use stable */ +fn ensure_timeout_symlink() -> std::path::PathBuf { + use std::path::PathBuf; + + /* get the procguard binary path from cargo */ + let procguard_path = assert_cmd::cargo::cargo_bin("procguard"); + + /* create timeout symlink next to procguard if it doesn't exist */ + let mut timeout_path = PathBuf::from(&procguard_path); + timeout_path.pop(); + timeout_path.push("timeout"); + + /* create symlink if missing (idempotent) */ + if !timeout_path.exists() { + #[cfg(unix)] + { + let _ = std::os::unix::fs::symlink("procguard", &timeout_path); + } + } + + timeout_path +} + +/* get the timeout binary path (symlink) as a string */ +fn timeout_bin_path() -> String { + ensure_timeout_symlink().to_string_lossy().into_owned() +} + #[allow(deprecated)] fn timeout_cmd() -> Command { - Command::cargo_bin("timeout").unwrap() + let timeout_path = ensure_timeout_symlink(); + Command::new(timeout_path) } /* ========================================================================= @@ -32,7 +62,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 +924,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 3de66bb..fe75640 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -14,14 +14,44 @@ use assert_cmd::Command; use predicates::prelude::*; use std::time::{Duration, Instant}; +/* helper to get the procguard binary and create timeout symlink if needed */ +#[allow(deprecated)] /* cargo_bin! macro requires nightly, we use stable */ +fn ensure_timeout_symlink() -> std::path::PathBuf { + use std::path::PathBuf; + + /* get the procguard binary path from cargo */ + let procguard_path = assert_cmd::cargo::cargo_bin("procguard"); + + /* create timeout symlink next to procguard if it doesn't exist */ + let mut timeout_path = PathBuf::from(&procguard_path); + timeout_path.pop(); + timeout_path.push("timeout"); + + /* create symlink if missing (idempotent) */ + if !timeout_path.exists() { + #[cfg(unix)] + { + let _ = std::os::unix::fs::symlink("procguard", &timeout_path); + } + } + + timeout_path +} + +/* get the timeout binary path (symlink) as a string */ +fn timeout_bin_path() -> String { + ensure_timeout_symlink().to_string_lossy().into_owned() +} + /* timeout alias - tests mostly use this for GNU compatibility */ #[allow(deprecated)] fn timeout_cmd() -> Command { - Command::cargo_bin("timeout").unwrap() + let timeout_path = ensure_timeout_symlink(); + Command::new(timeout_path) } /* procguard primary binary */ -#[allow(dead_code, deprecated)] +#[allow(dead_code, deprecated)] /* cargo_bin! macro requires nightly, we use stable */ fn procguard_cmd() -> Command { Command::cargo_bin("procguard").unwrap() } @@ -31,11 +61,7 @@ fn procguard_cmd() -> Command { * ========================================================================= */ #[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() @@ -831,7 +857,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", @@ -870,7 +896,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", @@ -1137,7 +1163,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()) @@ -1187,7 +1213,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()) @@ -1839,7 +1865,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()) @@ -1875,7 +1901,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()) @@ -2582,7 +2608,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() @@ -2600,7 +2626,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() @@ -2617,7 +2643,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() @@ -2649,7 +2675,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()) @@ -2673,7 +2699,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()) @@ -2693,7 +2719,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()) @@ -2749,7 +2775,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", @@ -2786,7 +2812,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()) @@ -2818,7 +2844,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() @@ -2844,7 +2870,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()) @@ -2873,7 +2899,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", @@ -2910,7 +2936,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()) @@ -2945,7 +2971,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() @@ -2981,7 +3007,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() @@ -3013,7 +3039,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() @@ -3040,7 +3066,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() @@ -3066,7 +3092,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", @@ -3122,7 +3148,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", From 31adb5fe248de1a33bcad0a14f1d7c00995781a9 Mon Sep 17 00:00:00 2001 From: denispol <19826856+denispol@users.noreply.github.com> Date: Sat, 31 Jan 2026 15:40:42 +0100 Subject: [PATCH 4/7] fix: restore dual binary targets for cargo install support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add back `timeout` binary target to Cargo.toml - `cargo install procguard` now installs both procguard and timeout - Fix README test count: 184 → 185 The cargo warning about shared main.rs is intentional and harmless. This ensures users get both binaries without manual symlink creation: cargo install procguard # installs both procguard and timeout --- Cargo.toml | 9 ++++++--- README.md | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 405f03c..8ec4dfa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,15 +67,18 @@ opt-level = 3 debug-assertions = true overflow-checks = true -# Primary binary - wall-clock default -# The `timeout` alias is created as a symlink during packaging/installation -# for GNU compatibility. Behavior is detected via argv[0] at runtime: +# 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" + [package.metadata.docs.rs] # docs.rs builds on linux by default; force building docs for macOS targets. targets = ["aarch64-apple-darwin", "x86_64-apple-darwin"] diff --git a/README.md b/README.md index 1152fb6..4af77d3 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,7 @@ procguard uses a **five-layer verification approach**: | Method | Coverage | What It Catches | | ------------------------------ | -------------------------- | ------------------------------------------------- | | **Unit tests** | 154 tests | Logic errors, edge cases | -| **Integration tests** | 184 tests | Real process behavior, signals, I/O | +| **Integration tests** | 185 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 | From a0305cbc74d61955b088aae8bdc3b001ad1c5436 Mon Sep 17 00:00:00 2001 From: denispol <19826856+denispol@users.noreply.github.com> Date: Sat, 31 Jan 2026 15:46:45 +0100 Subject: [PATCH 5/7] refactor: simplify test helpers and reposition README - Remove symlink creation from test helpers (cargo builds both binaries) - Use Command::cargo_bin("timeout") directly with #[allow(deprecated)] - Restructure README: procguard as unique process supervisor, not just timeout - Lead with resource limits, lifecycle control, formal verification - Move GNU timeout compatibility to secondary feature --- README.md | 94 +++++++++++++++----------------------------- tests/benchmarks.rs | 36 ++++------------- tests/integration.rs | 38 ++++-------------- 3 files changed, 47 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index 4af77d3..e46245d 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,56 @@ # procguard -The formally verified process supervisor for macOS. +The process supervisor for macOS. No equivalent exists. -**CLI tool** — process timeouts, resource limits, lifecycle control. -**Rust library** — embed supervision logic in your own tools. +**Resource enforcement** — memory limits, CPU throttling, time quotas. +**Process lifecycle** — coordinated startup, graceful shutdown, retry logic. +**Formal verification** — 19 mathematical proofs, not just tests. +**~100KB binary** — zero dependencies, instant startup. - brew install denispol/tap/procguard # CLI - cargo add procguard # library - -Works exactly like GNU timeout (it's a drop-in replacement): - - procguard 30s ./slow-command # kill after 30 seconds - procguard -k 5 1m ./stubborn # SIGTERM, then SIGKILL after 5s - procguard --preserve-status 10s ./cmd # exit with command's status - -Plus features GNU doesn't have: - - procguard --json 5m ./test-suite # JSON output for CI - procguard --on-timeout 'cleanup.sh' 30s ./task # pre-timeout hook - procguard --retry 3 30s ./flaky-test # retry on timeout - procguard --mem-limit 1G 1h ./build # kill if memory exceeds 1GB - procguard --cpu-percent 50 1h ./batch # throttle to 50% CPU - -## GNU Compatibility - -**Dual binary:** `procguard` is the primary binary. A `timeout` alias is also provided for scripts expecting GNU timeout. - -| Binary | Default Behavior | Use Case | -| ----------- | --------------------------------- | ------------------------- | -| `procguard` | Wall clock (survives sleep) | macOS-native, sleep-aware | -| `timeout` | Active time (pauses during sleep) | GNU-compatible scripts | + brew install denispol/tap/procguard ```bash -# These behave identically to GNU timeout: -timeout 30s ./command -timeout -k 5 1m ./stubborn - -# procguard defaults to wall-clock (unique to macOS): -procguard 30s ./command # survives system sleep -procguard -c active 30s ./command # GNU-like behavior +procguard --mem-limit 4G 2h make -j8 # kill if memory exceeds 4GB +procguard --cpu-percent 50 1h ./batch # throttle to 50% CPU +procguard --cpu-time 5m 1h ./compute # hard 5-minute CPU limit +procguard --wait-for-file /tmp/ready 5m ./app # coordinated startup +procguard --on-timeout 'cleanup.sh' 30s ./task # pre-kill hook +procguard --json --retry 3 5m ./test-suite # CI integration ``` ## Why procguard? -Apple doesn't ship `timeout`. The alternatives have problems: - -**GNU coreutils** (`brew install coreutils`): +macOS has no process supervisor. You can't: -- 15.7MB and 475 files for one command -- **Stops counting when your Mac sleeps** (uses `nanosleep`) +- Kill a build if it uses too much memory +- Throttle background jobs to save battery +- Coordinate service startup with dependencies +- Get structured output from process execution -**uutils** (Rust rewrite of coreutils): +**Docker/Kubernetes?** Overkill for local development. Requires containers. +**launchd?** No resource limits. No structured output. XML configuration hell. +**ulimit?** Only self-imposed. Child processes can ignore it. -- Also stops counting during sleep (uses `Instant`/`mach_absolute_time`) +procguard fills this gap: real process supervision, native macOS, zero overhead. -procguard 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. +## Also a timeout command -**Scenario:** `procguard 1h ./build` with laptop sleeping 45min in the middle +Apple doesn't ship `timeout`. procguard includes a `timeout` binary as a drop-in replacement for GNU coreutils: - 0 15min 1h 1h 45min - ├──────────┬──────────────────────────────┬──────────────────────────────┤ - Real time │▓▓▓▓▓▓▓▓▓▓│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓│ - │ awake │ sleep │ awake │ - └──────────┴──────────────────────────────┴──────────────────────────────┘ - - procguard |██████████|██████████████████████████████^ fires at 1h ✓ - (counts sleep time) - - GNU timeout |██████████|······························|██████████████████████████████^ fires at 1h 45min ✗ - (pauses during sleep) +```bash +timeout 30s ./command # GNU-compatible +timeout -k 5 1m ./stubborn # SIGTERM, then SIGKILL +procguard 30s ./command # same, but wall-clock default +``` - Legend: ▓ awake ░ sleep █ counting · paused ^ fire point +**Bonus:** procguard uses `mach_continuous_time`—the only macOS clock that survives system sleep. Set 1 hour, get 1 hour, even if you close your laptop. -| | procguard | GNU coreutils | +| Feature | procguard | GNU coreutils | | ------------------------- | ------------------ | --------------- | -| Works during system sleep | ✓ | ✗ | -| Selectable time mode | ✓ (wall/active) | ✗ (active only) | | **Resource limits** | ✓ (mem/CPU) | ✗ | | **Formal verification** | ✓ (19 kani proofs) | ✗ | +| Works during system sleep | ✓ | ✗ | +| Selectable time mode | ✓ (wall/active) | ✗ (active only) | | JSON output | ✓ | ✗ | | Retry on timeout | ✓ | ✗ | | Stdin idle timeout | ✓ | ✗ | @@ -88,12 +61,9 @@ procguard uses `mach_continuous_time`, the only macOS clock that keeps ticking t | 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 & Verification procguard uses a **five-layer verification approach**: diff --git a/tests/benchmarks.rs b/tests/benchmarks.rs index 6ca795e..770ecbf 100644 --- a/tests/benchmarks.rs +++ b/tests/benchmarks.rs @@ -22,39 +22,17 @@ use assert_cmd::Command; use std::time::{Duration, Instant}; -/* helper to get the procguard binary and create timeout symlink if needed */ -#[allow(deprecated)] /* cargo_bin! macro requires nightly, we use stable */ -fn ensure_timeout_symlink() -> std::path::PathBuf { - use std::path::PathBuf; - - /* get the procguard binary path from cargo */ - let procguard_path = assert_cmd::cargo::cargo_bin("procguard"); - - /* create timeout symlink next to procguard if it doesn't exist */ - let mut timeout_path = PathBuf::from(&procguard_path); - timeout_path.pop(); - timeout_path.push("timeout"); - - /* create symlink if missing (idempotent) */ - if !timeout_path.exists() { - #[cfg(unix)] - { - let _ = std::os::unix::fs::symlink("procguard", &timeout_path); - } - } - - timeout_path -} - -/* get the timeout binary path (symlink) as a string */ +/* 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 { - ensure_timeout_symlink().to_string_lossy().into_owned() + assert_cmd::cargo::cargo_bin("timeout") + .to_string_lossy() + .into_owned() } -#[allow(deprecated)] +#[allow(deprecated)] /* cargo_bin deprecated but cargo_bin! requires nightly */ fn timeout_cmd() -> Command { - let timeout_path = ensure_timeout_symlink(); - Command::new(timeout_path) + Command::cargo_bin("timeout").unwrap() } /* ========================================================================= diff --git a/tests/integration.rs b/tests/integration.rs index fe75640..21008da 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -14,44 +14,22 @@ use assert_cmd::Command; use predicates::prelude::*; use std::time::{Duration, Instant}; -/* helper to get the procguard binary and create timeout symlink if needed */ -#[allow(deprecated)] /* cargo_bin! macro requires nightly, we use stable */ -fn ensure_timeout_symlink() -> std::path::PathBuf { - use std::path::PathBuf; - - /* get the procguard binary path from cargo */ - let procguard_path = assert_cmd::cargo::cargo_bin("procguard"); - - /* create timeout symlink next to procguard if it doesn't exist */ - let mut timeout_path = PathBuf::from(&procguard_path); - timeout_path.pop(); - timeout_path.push("timeout"); - - /* create symlink if missing (idempotent) */ - if !timeout_path.exists() { - #[cfg(unix)] - { - let _ = std::os::unix::fs::symlink("procguard", &timeout_path); - } - } - - timeout_path -} - -/* get the timeout binary path (symlink) as a string */ +/* get the timeout binary path as a string */ +#[allow(deprecated)] /* cargo_bin deprecated but cargo_bin! requires nightly */ fn timeout_bin_path() -> String { - ensure_timeout_symlink().to_string_lossy().into_owned() + assert_cmd::cargo::cargo_bin("timeout") + .to_string_lossy() + .into_owned() } /* timeout alias - tests mostly use this for GNU compatibility */ -#[allow(deprecated)] +#[allow(deprecated)] /* cargo_bin deprecated but cargo_bin! requires nightly */ fn timeout_cmd() -> Command { - let timeout_path = ensure_timeout_symlink(); - Command::new(timeout_path) + Command::cargo_bin("timeout").unwrap() } /* procguard primary binary */ -#[allow(dead_code, deprecated)] /* cargo_bin! macro requires nightly, we use stable */ +#[allow(dead_code, deprecated)] /* cargo_bin deprecated but cargo_bin! requires nightly */ fn procguard_cmd() -> Command { Command::cargo_bin("procguard").unwrap() } From 7f24ea5c66662e7aa072a127e38b6f439581dcff Mon Sep 17 00:00:00 2001 From: denispol <19826856+denispol@users.noreply.github.com> Date: Sat, 31 Jan 2026 15:51:42 +0100 Subject: [PATCH 6/7] docs: rewrite README as unique macOS process supervisor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Lead with 'Linux has cgroups. macOS has nothing—until now.' - Add 'Built with Rust' section: no_std, 100KB, formal verification - Position timeout as bonus feature, not comparison battle - 'Exact GNU behavior' without defensive comparison tables - Clean problem/solution table without 'impossible' language - Add architecture highlights for Rust developers --- README.md | 165 +++++++++++++++++++++++++++++------------------------- 1 file changed, 90 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index e46245d..1018a0c 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,90 @@ # procguard -The process supervisor for macOS. No equivalent exists. +**The missing process supervisor for macOS.** -**Resource enforcement** — memory limits, CPU throttling, time quotas. -**Process lifecycle** — coordinated startup, graceful shutdown, retry logic. -**Formal verification** — 19 mathematical proofs, not just tests. -**~100KB binary** — zero dependencies, instant startup. - - brew install denispol/tap/procguard +Linux has cgroups. macOS has nothing—until now. ```bash procguard --mem-limit 4G 2h make -j8 # kill if memory exceeds 4GB procguard --cpu-percent 50 1h ./batch # throttle to 50% CPU procguard --cpu-time 5m 1h ./compute # hard 5-minute CPU limit procguard --wait-for-file /tmp/ready 5m ./app # coordinated startup -procguard --on-timeout 'cleanup.sh' 30s ./task # pre-kill hook procguard --json --retry 3 5m ./test-suite # CI integration ``` -## Why procguard? + brew install denispol/tap/procguard + +## Why This Exists + +macOS has no native way to: + +| Need | Without procguard | +|------|-------------------| +| Kill a build at 8GB RAM | Watch Activity Monitor manually | +| Throttle CPU to save battery | Run inside Docker | +| Get JSON from process execution | Write wrapper scripts | +| Timeout that survives sleep | Accept wrong behavior | -macOS has no process supervisor. You can't: +procguard handles all of this. No containers, no root, no daemons. -- Kill a build if it uses too much memory -- Throttle background jobs to save battery -- Coordinate service startup with dependencies -- Get structured output from process execution +## Built with Rust -**Docker/Kubernetes?** Overkill for local development. Requires containers. -**launchd?** No resource limits. No structured output. XML configuration hell. -**ulimit?** Only self-imposed. Child processes can ignore it. +procguard is a `no_std` Rust binary. No runtime dependencies. No allocator overhead. Just raw Darwin syscalls. + +| Metric | Value | +|--------|-------| +| Binary size | **~100KB** (universal arm64+x86_64) | +| Startup time | **3.6ms** | +| Memory overhead | **<1MB** | +| Dependencies | **0** (libc only) | +| Unsafe blocks | **19** (each formally verified) | + +This isn't "Rust for safety"—it's Rust for **precision**. Every byte matters when you're building system tooling. + +### Formal Verification + +Every unsafe block has a [Kani](https://github.com/model-checking/kani) proof. Not tests—**mathematical proofs** that the code cannot: +- Overflow on any arithmetic +- Dereference invalid memory +- Race on signal handling state + +``` +src/sync.rs → AtomicOnce initialization proof +src/throttle.rs → CPU throttle state machine proof +src/proc_info.rs → Buffer alignment proof +src/time_math.rs → Overflow-free time calculations +``` -procguard fills this gap: real process supervision, native macOS, zero overhead. +This is what Rust makes possible. See [docs/VERIFICATION.md](docs/VERIFICATION.md). -## Also a timeout command +## Includes GNU-compatible `timeout` -Apple doesn't ship `timeout`. procguard includes a `timeout` binary as a drop-in replacement for GNU coreutils: +Apple doesn't ship `timeout`. procguard includes a fully compatible implementation: ```bash -timeout 30s ./command # GNU-compatible -timeout -k 5 1m ./stubborn # SIGTERM, then SIGKILL -procguard 30s ./command # same, but wall-clock default +timeout 30s ./command # exact GNU behavior +timeout -k 5 1m ./stubborn # all flags work: -s, -k, -v, -p, -f +timeout --preserve-status 10s ./cmd ``` -**Bonus:** procguard uses `mach_continuous_time`—the only macOS clock that survives system sleep. Set 1 hour, get 1 hour, even if you close your laptop. - -| Feature | procguard | GNU coreutils | -| ------------------------- | ------------------ | --------------- | -| **Resource limits** | ✓ (mem/CPU) | ✗ | -| **Formal verification** | ✓ (19 kani proofs) | ✗ | -| Works during system sleep | ✓ | ✗ | -| Selectable time mode | ✓ (wall/active) | ✗ (active only) | -| 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 | - -_Performance data from [250 benchmark runs](#benchmarks) on Apple M4 Pro._ - -## Quality & Verification - -procguard uses a **five-layer verification approach**: - -| Method | Coverage | What It Catches | -| ------------------------------ | -------------------------- | ------------------------------------------------- | -| **Unit tests** | 154 tests | Logic errors, edge cases | -| **Integration tests** | 185 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. +Same exit codes. Same signal handling. Scripts written for Linux just work. + +The `procguard` binary adds features on top: resource limits, JSON output, retry logic, coordinated startup. Use whichever fits your workflow—both are installed together. + +**Bonus:** procguard uses `mach_continuous_time`, the only macOS clock that survives system sleep. A 1-hour timeout takes 1 hour of wall time, even if your laptop sleeps. + +## Testing + +| Method | Coverage | What It Catches | +|--------|----------|-----------------| +| Unit tests | 154 tests | Logic errors, edge cases | +| Integration tests | 185 tests | Real process behavior, signals, I/O | +| Library API tests | 10 tests | Public API usability | +| Property-based (proptest) | 30 properties | Input invariants | +| Fuzzing (cargo-fuzz) | 4 targets, ~70M executions | Crashes from malformed input | +| Formal verification (kani) | 19 proofs | Memory safety, no overflows | + +Fuzzing found and fixed bugs before release. Formal proofs guarantee the unsafe code is correct. ## Install @@ -92,19 +92,23 @@ See [docs/VERIFICATION.md](docs/VERIFICATION.md) for methodology details. brew install denispol/tap/procguard -**Binary download:** Grab the universal binary (arm64 + x86_64) from [releases](https://github.com/denispol/procguard/releases). +**Cargo:** -**From source (CLI):** + cargo install procguard # installs both procguard and timeout binaries + +**Binary download:** Universal binary (arm64 + x86_64) from [releases](https://github.com/denispol/procguard/releases). + +**From source:** cargo build --release sudo cp target/release/procguard /usr/local/bin/ - sudo ln -s procguard /usr/local/bin/timeout # optional: GNU-compatible alias + sudo cp target/release/timeout /usr/local/bin/ **As a Rust library:** cargo add procguard -Shell completions are installed automatically with Homebrew. For manual install, see [completions/](completions/). +Shell completions installed automatically with Homebrew. For manual install, see [completions/](completions/). ## Quick Start @@ -274,14 +278,25 @@ match run_command("sh", &args, &config) { ## Development - cargo test # run tests - cargo test --test proptest # property-based tests - cargo clippy # lint - ./scripts/verify-all.sh # full verification suite +procguard is a learning resource for `no_std` Rust and Darwin systems programming. + +```bash +cargo test # 349 tests +cargo test --test proptest # property-based tests +cargo clippy # lint +./scripts/verify-all.sh # full verification (tests + fuzz + kani) +``` + +**Architecture highlights:** +- `src/runner.rs` — kqueue event loop, zero-CPU waiting +- `src/process.rs` — posix_spawn wrapper, lighter than fork+exec +- `src/throttle.rs` — CPU throttling via SIGSTOP/SIGCONT integral control +- `src/proc_info.rs` — Darwin libproc API for memory stats +- `src/time_math.rs` — checked arithmetic, no overflow possible -**Contributing:** See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow. +**Contributing:** See [CONTRIBUTING.md](CONTRIBUTING.md). -**Verification:** See [docs/VERIFICATION.md](docs/VERIFICATION.md) for testing methodology. +**Verification methodology:** See [docs/VERIFICATION.md](docs/VERIFICATION.md). ## License From 2299f22fdd01dc068c448678910f1d44ec0945e6 Mon Sep 17 00:00:00 2001 From: denispol <19826856+denispol@users.noreply.github.com> Date: Sat, 31 Jan 2026 15:58:51 +0100 Subject: [PATCH 7/7] docs: rewrite README for procguard identity - Position as process supervisor, not timeout replacement - Lead with resource limits (memory, CPU) - unique on macOS - Clean examples section organized by use case - 'For Rust Developers' section with architecture highlights - GNU timeout as compatible bonus, not comparison battle - Concise reference section --- README.md | 332 +++++++++++++----------------------------------------- 1 file changed, 81 insertions(+), 251 deletions(-) diff --git a/README.md b/README.md index 1018a0c..1d808c2 100644 --- a/README.md +++ b/README.md @@ -1,302 +1,132 @@ # procguard -**The missing process supervisor for macOS.** - -Linux has cgroups. macOS has nothing—until now. +Kill runaway processes before they freeze your Mac. ```bash -procguard --mem-limit 4G 2h make -j8 # kill if memory exceeds 4GB -procguard --cpu-percent 50 1h ./batch # throttle to 50% CPU -procguard --cpu-time 5m 1h ./compute # hard 5-minute CPU limit -procguard --wait-for-file /tmp/ready 5m ./app # coordinated startup -procguard --json --retry 3 5m ./test-suite # CI integration +procguard --mem-limit 4G 2h make -j8 ``` - brew install denispol/tap/procguard - -## Why This Exists - -macOS has no native way to: - -| Need | Without procguard | -|------|-------------------| -| Kill a build at 8GB RAM | Watch Activity Monitor manually | -| Throttle CPU to save battery | Run inside Docker | -| Get JSON from process execution | Write wrapper scripts | -| Timeout that survives sleep | Accept wrong behavior | - -procguard handles all of this. No containers, no root, no daemons. +macOS has no native memory limits or timeout command. procguard adds both. -## Built with Rust - -procguard is a `no_std` Rust binary. No runtime dependencies. No allocator overhead. Just raw Darwin syscalls. - -| Metric | Value | -|--------|-------| -| Binary size | **~100KB** (universal arm64+x86_64) | -| Startup time | **3.6ms** | -| Memory overhead | **<1MB** | -| Dependencies | **0** (libc only) | -| Unsafe blocks | **19** (each formally verified) | - -This isn't "Rust for safety"—it's Rust for **precision**. Every byte matters when you're building system tooling. - -### Formal Verification + brew install denispol/tap/procguard -Every unsafe block has a [Kani](https://github.com/model-checking/kani) proof. Not tests—**mathematical proofs** that the code cannot: -- Overflow on any arithmetic -- Dereference invalid memory -- Race on signal handling state +## What else? +```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 ``` -src/sync.rs → AtomicOnce initialization proof -src/throttle.rs → CPU throttle state machine proof -src/proc_info.rs → Buffer alignment proof -src/time_math.rs → Overflow-free time calculations -``` - -This is what Rust makes possible. See [docs/VERIFICATION.md](docs/VERIFICATION.md). -## Includes GNU-compatible `timeout` +## GNU-compatible `timeout` -Apple doesn't ship `timeout`. procguard includes a fully compatible implementation: +Apple doesn't ship one. procguard does: ```bash -timeout 30s ./command # exact GNU behavior -timeout -k 5 1m ./stubborn # all flags work: -s, -k, -v, -p, -f -timeout --preserve-status 10s ./cmd +timeout 30s ./command # GNU-compatible +timeout -k 5 1m ./stubborn # all the flags work ``` -Same exit codes. Same signal handling. Scripts written for Linux just work. - -The `procguard` binary adds features on top: resource limits, JSON output, retry logic, coordinated startup. Use whichever fits your workflow—both are installed together. - -**Bonus:** procguard uses `mach_continuous_time`, the only macOS clock that survives system sleep. A 1-hour timeout takes 1 hour of wall time, even if your laptop sleeps. - -## Testing - -| Method | Coverage | What It Catches | -|--------|----------|-----------------| -| Unit tests | 154 tests | Logic errors, edge cases | -| Integration tests | 185 tests | Real process behavior, signals, I/O | -| Library API tests | 10 tests | Public API usability | -| Property-based (proptest) | 30 properties | Input invariants | -| Fuzzing (cargo-fuzz) | 4 targets, ~70M executions | Crashes from malformed input | -| Formal verification (kani) | 19 proofs | Memory safety, no overflows | - -Fuzzing found and fixed bugs before release. Formal proofs guarantee the unsafe code is correct. +Same behavior, same exit codes. Your Linux scripts just work. ## Install -**Homebrew** (recommended): - - brew install denispol/tap/procguard - -**Cargo:** - - cargo install procguard # installs both procguard and timeout binaries - -**Binary download:** Universal binary (arm64 + x86_64) from [releases](https://github.com/denispol/procguard/releases). - -**From source:** - - cargo build --release - sudo cp target/release/procguard /usr/local/bin/ - sudo cp target/release/timeout /usr/local/bin/ - -**As a Rust library:** - - cargo add procguard - -Shell completions installed automatically with Homebrew. For manual install, see [completions/](completions/). - -## Quick Start - - procguard 30 ./slow-command # kill after 30 seconds - procguard -k 5 30 ./stubborn # SIGTERM, then SIGKILL after 5s - procguard --json 1m ./build # JSON output for CI - procguard -v 10 ./script # verbose: shows signals sent - -## Use Cases - -**CI/CD**: Stop flaky tests before they hang your pipeline. - - procguard --json 5m ./run-tests - -**Overnight builds**: Timeouts that work even when your Mac sleeps. - - procguard 2h make release # 2 hours wall-clock, guaranteed - -**Network ops**: Don't wait forever for unresponsive servers. - - procguard 10s curl https://api.example.com/health - -**Script safety**: Ensure cleanup scripts actually finish. - - procguard -k 10s 60s ./cleanup.sh - -**Coordinated startup**: Wait for dependencies before running. - - procguard --wait-for-file /tmp/db-ready 5m ./migrate - -**Prompt detection**: Kill commands that unexpectedly prompt for input. - - procguard --stdin-timeout 5s ./test-suite # fail if it prompts for input - -**Stream watchdog**: Detect stalled data pipelines without consuming the stream. - - pg_dump mydb | procguard -S 2m --stdin-passthrough 4h gzip | \ - aws s3 cp - s3://backups/db-$(date +%Y%m%d).sql.gz - -**CI keep-alive**: Prevent CI systems from killing long jobs. - - procguard --heartbeat 60s 2h ./integration-tests - -**Resource sandboxing**: Enforce memory and CPU limits. - - procguard --mem-limit 4G 2h make -j8 - procguard --cpu-percent 50 1h ./batch-process - -## Options - - procguard [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 - -**procguard 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 - --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 - -**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 (custom via --timeout-exit-code) - 125 procguard itself failed - 126 command found but not executable - 127 command not found - 128+N command killed by signal N - -## Time Modes - -**wall** (default for `procguard`): Real elapsed time, including system sleep. - - procguard 1h ./build # fires after 1 hour wall-clock - -**active** (default for `timeout` alias): Only counts time when awake. Matches GNU behavior. - - procguard -c active 1h ./benchmark # pauses during sleep - timeout 1h ./benchmark # same (timeout alias defaults to active) - -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. - -**Memory limit** (`--mem-limit`): Kill process if physical memory exceeds threshold. - - procguard --mem-limit 2G 1h ./memory-hungry-app - -**CPU time limit** (`--cpu-time`): Hard limit on total CPU seconds consumed. - - procguard --cpu-time 5m 1h ./compute-job - -**CPU throttle** (`--cpu-percent`): Actively limit CPU usage percentage. - - procguard --cpu-percent 50 1h ./background-task +```bash +brew install denispol/tap/procguard +# or +cargo install procguard +``` -See [docs/resource-limits.md](docs/resource-limits.md) for details. +Both install `procguard` and `timeout` binaries. -## JSON Output +## The Rust stuff -Machine-readable output for CI/CD pipelines: +`no_std`. ~100KB. 3.6ms startup. Zero dependencies beyond libc. - $ procguard --json 1s sleep 0.5 - {"schema_version":8,"status":"completed","exit_code":0,"elapsed_ms":504,...} +Built on Darwin internals most people never touch: -See [docs/json-output.md](docs/json-output.md) for complete schema. +- **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 `procguard` crate exposes the core timeout functionality for embedding in your own tools: +### As a library ```rust -use procguard::{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 + +## Reference + +``` +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' +``` -**API Docs:** [docs.rs/procguard](https://docs.rs/procguard) +`wall` = real time including sleep. `active` = pauses during sleep (GNU-compatible). -> ⚠️ **Stability:** The library API is experimental. Use `..RunConfig::default()` when constructing configs. +**Exit codes:** 0 ok, 124 timeout, 125 error, 126 not executable, 127 not found, 128+N signal ## Development -procguard is a learning resource for `no_std` Rust and Darwin systems programming. - ```bash -cargo test # 349 tests -cargo test --test proptest # property-based tests -cargo clippy # lint -./scripts/verify-all.sh # full verification (tests + fuzz + kani) +cargo test # 349 tests +./scripts/verify-all.sh # + fuzz + kani proofs ``` -**Architecture highlights:** -- `src/runner.rs` — kqueue event loop, zero-CPU waiting -- `src/process.rs` — posix_spawn wrapper, lighter than fork+exec -- `src/throttle.rs` — CPU throttling via SIGSTOP/SIGCONT integral control -- `src/proc_info.rs` — Darwin libproc API for memory stats -- `src/time_math.rs` — checked arithmetic, no overflow possible - -**Contributing:** See [CONTRIBUTING.md](CONTRIBUTING.md). - -**Verification methodology:** See [docs/VERIFICATION.md](docs/VERIFICATION.md). +[CONTRIBUTING.md](CONTRIBUTING.md) · [docs/VERIFICATION.md](docs/VERIFICATION.md) ## License