├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── README.template.md ├── Taskfile.yml ├── completions ├── install.fish ├── recur.bash └── recur.fish ├── go.mod ├── go.sum ├── main.go ├── main_test.go ├── script ├── release.go └── render_template.go └── test ├── env.go ├── exit99.go ├── hello.go └── sleep.go /.gitattributes: -------------------------------------------------------------------------------- 1 | /*.lock -diff 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | GO_VERSION: '1.19' 7 | TASK_VERSION: 'v3.28' 8 | 9 | jobs: 10 | bsd: 11 | runs-on: ${{ matrix.os.host }} 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | os: 16 | - name: freebsd 17 | architecture: x86-64 18 | version: '14.1' 19 | host: ubuntu-latest 20 | 21 | - name: netbsd 22 | architecture: x86-64 23 | version: '10.0' 24 | host: ubuntu-latest 25 | 26 | - name: openbsd 27 | architecture: x86-64 28 | version: '7.5' 29 | host: ubuntu-latest 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v4 33 | 34 | - name: Run CI script on ${{ matrix.os.name }} 35 | uses: cross-platform-actions/action@v0.25.0 36 | with: 37 | operating_system: ${{ matrix.os.name }} 38 | architecture: ${{ matrix.os.architecture }} 39 | version: ${{ matrix.os.version }} 40 | shell: bash 41 | run: | 42 | case "$(uname)" in 43 | FreeBSD) 44 | sudo pkg install -y go 45 | ;; 46 | NetBSD) 47 | sudo pkgin -y install go 48 | 49 | for bin in /usr/pkg/bin/go1*; do 50 | src=$bin 51 | done 52 | sudo ln -s "$src" /usr/pkg/bin/go 53 | ;; 54 | OpenBSD) 55 | sudo pkg_add -I go 56 | ;; 57 | esac 58 | PATH=$(go env GOPATH)/bin:$PATH 59 | 60 | go install 'github.com/go-task/task/v3/cmd/task@${{ env.TASK_VERSION }}' 61 | task 62 | 63 | linux: 64 | runs-on: ubuntu-latest 65 | steps: 66 | - name: Checkout 67 | uses: actions/checkout@v4 68 | 69 | - name: Set up Go 70 | uses: actions/setup-go@v5 71 | with: 72 | go-version: ${{ env.GO_VERSION }} 73 | 74 | - name: Install Task 75 | run: | 76 | go install github.com/go-task/task/v3/cmd/task@"$TASK_VERSION" 77 | 78 | - name: Build and test 79 | run: | 80 | task 81 | 82 | mac: 83 | runs-on: macos-latest 84 | steps: 85 | - name: Checkout 86 | uses: actions/checkout@v4 87 | 88 | - name: Set up Go 89 | uses: actions/setup-go@v5 90 | with: 91 | go-version: ${{ env.GO_VERSION }} 92 | 93 | - name: Install Task 94 | run: | 95 | go install github.com/go-task/task/v3/cmd/task@"$TASK_VERSION" 96 | 97 | - name: Build and test 98 | run: | 99 | task 100 | 101 | windows: 102 | runs-on: windows-latest 103 | steps: 104 | - name: 'Disable `autocrlf` in Git' 105 | run: git config --global core.autocrlf false 106 | 107 | - name: Checkout 108 | uses: actions/checkout@v4 109 | 110 | - name: Set up Go 111 | uses: actions/setup-go@v5 112 | with: 113 | go-version: ${{ env.GO_VERSION }} 114 | 115 | - name: Install Task 116 | run: | 117 | go install github.com/go-task/task/v3/cmd/task@$env:TASK_VERSION 118 | 119 | - name: Build and test 120 | run: | 121 | task 122 | 123 | - name: Upload Windows binary 124 | uses: actions/upload-artifact@v4 125 | with: 126 | name: recur-windows-amd64 127 | path: | 128 | recur.exe 129 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /attic/ 2 | /dist/ 3 | /.task/ 4 | 5 | *.bak 6 | *.exe 7 | *.swp 8 | 9 | /help 10 | /recur 11 | /test/env 12 | /test/exit99 13 | /test/hello 14 | /test/sleep 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023-2025 D. Bohdan 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # recur 2 | 3 | **recur** is a command-line tool that runs a single command repeatedly until it succeeds or no more attempts are left. 4 | It implements optional [exponential backoff](https://en.wikipedia.org/wiki/Exponential_backoff) with configurable [jitter](https://en.wikipedia.org/wiki/Thundering_herd_problem#Mitigation). 5 | It lets you write the success condition in Starlark. 6 | 7 | ## Installation 8 | 9 | ### Prebuilt binaries 10 | 11 | Prebuilt binaries for 12 | FreeBSD (amd64), 13 | Linux (aarch64, riscv64, x86_64), 14 | macOS (arm64, x86_64), 15 | NetBSD (amd64), 16 | OpenBSD (amd64), 17 | and Windows (amd64, arm64, x86) 18 | are attached to [releases](https://github.com/dbohdan/recur/releases). 19 | 20 | ### Go 21 | 22 | Install Go, then run the following command: 23 | 24 | ```shell 25 | go install dbohdan.com/recur/v2@latest 26 | ``` 27 | 28 | ## Build requirements 29 | 30 | - Go 1.19 31 | - [Task](https://taskfile.dev/) (go-task) 3.28 32 | 33 | ## Usage 34 | 35 | ```none 36 | Usage: recur [-h] [-V] [-a ] [-b ] [-c ] [-d 37 | ] [-F] [-f] [-j ] [-m ] [-r ] [-t 38 | ] [-v] [--] [ ...] 39 | 40 | Retry a command with exponential backoff and jitter. 41 | 42 | Arguments: 43 | 44 | Command to run 45 | 46 | [ ...] 47 | Arguments to the command 48 | 49 | Options: 50 | -h, --help 51 | Print this help message and exit 52 | 53 | -V, --version 54 | Print version number and exit 55 | 56 | -a, --attempts 10 57 | Maximum number of attempts (negative for infinite) 58 | 59 | -b, --backoff 0 60 | Base for exponential backoff (duration) 61 | 62 | -c, --condition 'code == 0' 63 | Success condition (Starlark expression) 64 | 65 | -d, --delay 0 66 | Constant delay (duration) 67 | 68 | -F, --fib 69 | Add Fibonacci backoff 70 | 71 | -f, --forever 72 | Infinite attempts 73 | 74 | -j, --jitter '0,0' 75 | Additional random delay (maximum duration or 'min,max' duration) 76 | 77 | -m, --max-delay 1h 78 | Maximum allowed sum of constant delay, exponential backoff, and 79 | Fibonacci backoff (duration) 80 | 81 | -r, --reset -1s 82 | Minimum attempt time that resets exponential and Fibonacci backoff 83 | (duration; negative for no reset) 84 | 85 | -t, --timeout -1s 86 | Timeout for each attempt (duration; negative for no timeout) 87 | 88 | -v, --verbose 89 | Increase verbosity (up to 3 times) 90 | ``` 91 | 92 | The "duration" arguments take [Go duration strings](https://pkg.go.dev/time#ParseDuration); 93 | for example, `0`, `100ms`, `2.5s`, `0.5m`, or `1h`. 94 | The value of `-j`/`--jitter` must be either one duration string or two joined with a comma, like `1s,2s`. 95 | 96 | Setting the delay (`-d`/`--delay`) increases the maximum delay (`-m`/`--max-delay`) to that value when the maximum delay is shorter. 97 | Use `-m`/`--max-delay` after `-d`/`--delay` if you want a shorter maximum delay. 98 | 99 | The following recur options run the command `foo --config bar.cfg` indefinitely. 100 | Every time `foo` exits, there is a delay that grows exponentially from two seconds to a minute. 101 | The delay resets back to two seconds if the command runs for at least five minutes. 102 | 103 | ```shell 104 | recur --backoff 2s --condition False --forever --max-delay 1m --reset 5m foo --config bar.cfg 105 | ``` 106 | 107 | recur exits with the last command's exit code unless the user overrides this in the condition. 108 | When the command is not found during the last attempt, 109 | recur exits with the code 255. 110 | 111 | recur sets the environment variable `RECUR_ATTEMPT` for the command it runs to the current attempt number. 112 | This way the command can access the attempt counter. 113 | recur also sets `RECUR_MAX_ATTEMPTS` to the value of `-a`/`--attempts` 114 | and `RECUR_ATTEMPT_SINCE_RESET` to the attempt number since exponential and Fibonacci backoff were reset. 115 | 116 | The following command succeeds on the last attempt: 117 | 118 | ```none 119 | $ recur sh -c 'echo "Attempt $RECUR_ATTEMPT of $RECUR_MAX_ATTEMPTS"; exit $((RECUR_MAX_ATTEMPTS - RECUR_ATTEMPT))' 120 | Attempt 1 of 10 121 | Attempt 2 of 10 122 | Attempt 3 of 10 123 | Attempt 4 of 10 124 | Attempt 5 of 10 125 | Attempt 6 of 10 126 | Attempt 7 of 10 127 | Attempt 8 of 10 128 | Attempt 9 of 10 129 | Attempt 10 of 10 130 | ``` 131 | 132 | ## Conditions 133 | 134 | recur supports a limited form of scripting. 135 | You can define the success condition using an expression in [Starlark](https://laurent.le-brun.eu/blog/an-overview-of-starlark), a small scripting language derived from Python. 136 | The default condition is `code == 0`. 137 | It means recur will stop retrying when the exit code of the command is zero. 138 | 139 | If you know Python, you can quickly start writing recur conditions in Starlark. 140 | The most significant differences between Starlark and Python for this purpose are: 141 | 142 | - Starlark has no `is`. 143 | You must write `code == None`, not `code is None`. 144 | - Starlark has no sets. 145 | Write `code in (1, 2, 3)` or `code in [1, 2, 3]` rather than `code in {1, 2, 3}`. 146 | 147 | You can use the following variables in the condition expression: 148 | 149 | - `attempt`: `int` — the number of the current attempt, starting at one. 150 | Combine with `--forever` to use the condition instead of the built-in attempt counter. 151 | - `attempt_since_reset`: `int` — the attempt number since exponential and Fibonacci backoff were reset, starting at one. 152 | - `code`: `int | None` — the exit code of the last command. 153 | `code` is `None` when the command was not found. 154 | - `command_found`: `bool` — whether the last command was found. 155 | - `max_attempts`: `int` — the value of the option `--attempts`. 156 | `--forever` sets it to -1. 157 | - `time`: `float` — the time the most recent attempt took, in seconds. 158 | - `total_time`: `float` — the time between the start of the first attempt and the end of the most recent, again in seconds. 159 | 160 | recur defines two custom functions: 161 | 162 | - `exit(code: int | None) -> None` — exit with the exit code. 163 | If `code` is `None`, exit with the exit code for a missing command (255). 164 | - `inspect(value: Any, *, prefix: str = "") -> Any` — log `value` prefixed by `prefix` and return `value`. 165 | This is useful for debugging. 166 | 167 | The `exit` function allows you to override the default behavior of returning the last command's exit code. 168 | For example, you can make recur exit with success when the command fails. 169 | 170 | ```shell 171 | recur --condition 'code != 0 and exit(0)' sh -c 'exit 1' 172 | # or 173 | recur --condition 'False if code == 0 else exit(0)' sh -c 'exit 1' 174 | ``` 175 | 176 | In the following example we stop early and do not retry when the command's exit code indicates incorrect usage or a problem with the installation. 177 | 178 | ```shell 179 | recur --condition 'code == 0 or (code in (1, 2, 3, 4) and exit(code))' curl "$url" 180 | ``` 181 | 182 | ## License 183 | 184 | MIT. 185 | 186 | ## Alternatives 187 | 188 | recur was inspired by [retry-cli](https://github.com/tirsen/retry-cli). 189 | I wanted something like retry-cli but without the Node.js dependency. 190 | 191 | There are other similar tools: 192 | 193 | - [eb](https://github.com/rye/eb). 194 | Written in Rust. 195 | `cargo install eb`. 196 | - [retry (joshdk)](https://github.com/joshdk/retry). 197 | Written in Go. 198 | `go install github.com/joshdk/retry@master`. 199 | - [retry (kadwanev)](https://github.com/kadwanev/retry). 200 | Written in Bash. 201 | - [retry (minfrin)](https://github.com/minfrin/retry). 202 | Written in C. 203 | Packaged for Debian and Ubuntu. 204 | `sudo apt install retry`. 205 | - [retry (timofurrer)](https://github.com/timofurrer/retry-cmd). 206 | Written in Rust. 207 | `cargo install retry-cmd`. 208 | - [retry-cli](https://github.com/tirsen/retry-cli). 209 | Written in JavaScript for Node.js. 210 | `npx retry-cli`. 211 | - [SysBox](https://github.com/skx/sysbox) includes the command `splay`. 212 | Written in Go. 213 | `go install github.com/skx/sysbox@latest`. 214 | -------------------------------------------------------------------------------- /README.template.md: -------------------------------------------------------------------------------- 1 | # recur 2 | 3 | **recur** is a command-line tool that runs a single command repeatedly until it succeeds or no more attempts are left. 4 | It implements optional [exponential backoff](https://en.wikipedia.org/wiki/Exponential_backoff) with configurable [jitter](https://en.wikipedia.org/wiki/Thundering_herd_problem#Mitigation). 5 | It lets you write the success condition in Starlark. 6 | 7 | ## Installation 8 | 9 | ### Prebuilt binaries 10 | 11 | Prebuilt binaries for 12 | FreeBSD (amd64), 13 | Linux (aarch64, riscv64, x86_64), 14 | macOS (arm64, x86_64), 15 | NetBSD (amd64), 16 | OpenBSD (amd64), 17 | and Windows (amd64, arm64, x86) 18 | are attached to [releases](https://github.com/dbohdan/recur/releases). 19 | 20 | ### Go 21 | 22 | Install Go, then run the following command: 23 | 24 | ```shell 25 | go install dbohdan.com/recur/v2@latest 26 | ``` 27 | 28 | ## Build requirements 29 | 30 | - Go 1.19 31 | - [Task](https://taskfile.dev/) (go-task) 3.28 32 | 33 | ## Usage 34 | 35 | ```none 36 | {{ .Help | wrap 80 -}} 37 | ``` 38 | 39 | The "duration" arguments take [Go duration strings](https://pkg.go.dev/time#ParseDuration); 40 | for example, `0`, `100ms`, `2.5s`, `0.5m`, or `1h`. 41 | The value of `-j`/`--jitter` must be either one duration string or two joined with a comma, like `1s,2s`. 42 | 43 | Setting the delay (`-d`/`--delay`) increases the maximum delay (`-m`/`--max-delay`) to that value when the maximum delay is shorter. 44 | Use `-m`/`--max-delay` after `-d`/`--delay` if you want a shorter maximum delay. 45 | 46 | The following recur options run the command `foo --config bar.cfg` indefinitely. 47 | Every time `foo` exits, there is a delay that grows exponentially from two seconds to a minute. 48 | The delay resets back to two seconds if the command runs for at least five minutes. 49 | 50 | ```shell 51 | recur --backoff 2s --condition False --forever --max-delay 1m --reset 5m foo --config bar.cfg 52 | ``` 53 | 54 | recur exits with the last command's exit code unless the user overrides this in the condition. 55 | When the command is not found during the last attempt, 56 | recur exits with the code 255. 57 | 58 | recur sets the environment variable `RECUR_ATTEMPT` for the command it runs to the current attempt number. 59 | This way the command can access the attempt counter. 60 | recur also sets `RECUR_MAX_ATTEMPTS` to the value of `-a`/`--attempts` 61 | and `RECUR_ATTEMPT_SINCE_RESET` to the attempt number since exponential and Fibonacci backoff were reset. 62 | 63 | The following command succeeds on the last attempt: 64 | 65 | ```none 66 | $ recur sh -c 'echo "Attempt $RECUR_ATTEMPT of $RECUR_MAX_ATTEMPTS"; exit $((RECUR_MAX_ATTEMPTS - RECUR_ATTEMPT))' 67 | Attempt 1 of 10 68 | Attempt 2 of 10 69 | Attempt 3 of 10 70 | Attempt 4 of 10 71 | Attempt 5 of 10 72 | Attempt 6 of 10 73 | Attempt 7 of 10 74 | Attempt 8 of 10 75 | Attempt 9 of 10 76 | Attempt 10 of 10 77 | ``` 78 | 79 | ## Conditions 80 | 81 | recur supports a limited form of scripting. 82 | You can define the success condition using an expression in [Starlark](https://laurent.le-brun.eu/blog/an-overview-of-starlark), a small scripting language derived from Python. 83 | The default condition is `code == 0`. 84 | It means recur will stop retrying when the exit code of the command is zero. 85 | 86 | If you know Python, you can quickly start writing recur conditions in Starlark. 87 | The most significant differences between Starlark and Python for this purpose are: 88 | 89 | - Starlark has no `is`. 90 | You must write `code == None`, not `code is None`. 91 | - Starlark has no sets. 92 | Write `code in (1, 2, 3)` or `code in [1, 2, 3]` rather than `code in {1, 2, 3}`. 93 | 94 | You can use the following variables in the condition expression: 95 | 96 | - `attempt`: `int` — the number of the current attempt, starting at one. 97 | Combine with `--forever` to use the condition instead of the built-in attempt counter. 98 | - `attempt_since_reset`: `int` — the attempt number since exponential and Fibonacci backoff were reset, starting at one. 99 | - `code`: `int | None` — the exit code of the last command. 100 | `code` is `None` when the command was not found. 101 | - `command_found`: `bool` — whether the last command was found. 102 | - `max_attempts`: `int` — the value of the option `--attempts`. 103 | `--forever` sets it to -1. 104 | - `time`: `float` — the time the most recent attempt took, in seconds. 105 | - `total_time`: `float` — the time between the start of the first attempt and the end of the most recent, again in seconds. 106 | 107 | recur defines two custom functions: 108 | 109 | - `exit(code: int | None) -> None` — exit with the exit code. 110 | If `code` is `None`, exit with the exit code for a missing command (255). 111 | - `inspect(value: Any, *, prefix: str = "") -> Any` — log `value` prefixed by `prefix` and return `value`. 112 | This is useful for debugging. 113 | 114 | The `exit` function allows you to override the default behavior of returning the last command's exit code. 115 | For example, you can make recur exit with success when the command fails. 116 | 117 | ```shell 118 | recur --condition 'code != 0 and exit(0)' sh -c 'exit 1' 119 | # or 120 | recur --condition 'False if code == 0 else exit(0)' sh -c 'exit 1' 121 | ``` 122 | 123 | In the following example we stop early and do not retry when the command's exit code indicates incorrect usage or a problem with the installation. 124 | 125 | ```shell 126 | recur --condition 'code == 0 or (code in (1, 2, 3, 4) and exit(code))' curl "$url" 127 | ``` 128 | 129 | ## License 130 | 131 | MIT. 132 | 133 | ## Alternatives 134 | 135 | recur was inspired by [retry-cli](https://github.com/tirsen/retry-cli). 136 | I wanted something like retry-cli but without the Node.js dependency. 137 | 138 | There are other similar tools: 139 | 140 | - [eb](https://github.com/rye/eb). 141 | Written in Rust. 142 | `cargo install eb`. 143 | - [retry (joshdk)](https://github.com/joshdk/retry). 144 | Written in Go. 145 | `go install github.com/joshdk/retry@master`. 146 | - [retry (kadwanev)](https://github.com/kadwanev/retry). 147 | Written in Bash. 148 | - [retry (minfrin)](https://github.com/minfrin/retry). 149 | Written in C. 150 | Packaged for Debian and Ubuntu. 151 | `sudo apt install retry`. 152 | - [retry (timofurrer)](https://github.com/timofurrer/retry-cmd). 153 | Written in Rust. 154 | `cargo install retry-cmd`. 155 | - [retry-cli](https://github.com/tirsen/retry-cli). 156 | Written in JavaScript for Node.js. 157 | `npx retry-cli`. 158 | - [SysBox](https://github.com/skx/sysbox) includes the command `splay`. 159 | Written in Go. 160 | `go install github.com/skx/sysbox@latest`. 161 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | vars: 4 | dist_dir: dist/ 5 | ext: '{{if eq OS "windows"}}.exe{{end}}' 6 | test_binaries: | 7 | test/env 8 | test/exit99 9 | test/hello 10 | test/sleep 11 | 12 | env: 13 | CGO_ENABLED: 0 14 | 15 | tasks: 16 | default: 17 | deps: 18 | - all 19 | 20 | all: 21 | desc: 'Build and test everything' 22 | deps: 23 | - build 24 | - test 25 | 26 | build: 27 | desc: 'Build all components' 28 | deps: 29 | - build_readme 30 | - build_binaries 31 | 32 | build_binaries: 33 | desc: 'Build all necessary binaries' 34 | deps: 35 | - build_recur 36 | - build_test_binaries 37 | 38 | build_binary: 39 | desc: 'Build a single Go binary' 40 | internal: true 41 | cmds: 42 | - go build -o {{.out | shellQuote}}{{.ext}} {{.src | shellQuote}} 43 | 44 | build_readme: 45 | desc: 'Generate README.md from template' 46 | deps: 47 | - build_recur 48 | cmds: 49 | - go run script/render_template.go < README.template.md > README.md 50 | status: 51 | - README.template.md 52 | - main.go 53 | generates: 54 | - README.md 55 | 56 | build_recur: 57 | desc: 'Build the recur binary' 58 | cmds: 59 | - task: build_binary 60 | vars: 61 | out: recur 62 | src: main.go 63 | sources: 64 | - main.go 65 | generates: 66 | - recur{{.ext}} 67 | 68 | build_test_binaries: 69 | desc: 'Build all test binaries' 70 | cmds: 71 | - task: build_binary 72 | vars: 73 | src: '{{.test_binary}}.go' 74 | out: '{{.test_binary}}' 75 | for: 76 | var: test_binaries 77 | as: test_binary 78 | sources: 79 | - test/env.go 80 | - test/exit99.go 81 | - test/hello.go 82 | - test/sleep.go 83 | generates: 84 | - test/env{{.ext}} 85 | - test/exit99{{.ext}} 86 | - test/hello{{.ext}} 87 | - test/sleep{{.ext}} 88 | 89 | clean: 90 | desc: 'Clean up binaries and generated files' 91 | cmds: 92 | - rm -f README.md 93 | - rm -f recur{{.ext}} 94 | - cmd: rm -f {{.test_binary | shellQuote}}{{.ext}} 95 | for: 96 | var: test_binaries 97 | as: test_binary 98 | 99 | release: 100 | desc: 'Prepare a release' 101 | deps: 102 | - build_binaries 103 | cmds: 104 | - VERSION=$(./recur{{.ext}} --version) go run script/release.go 105 | 106 | test: 107 | desc: 'Run tests' 108 | deps: 109 | - build_binaries 110 | cmds: 111 | - go test 112 | -------------------------------------------------------------------------------- /completions/install.fish: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env fish 2 | 3 | cd "$(path dirname "$(status filename)")" 4 | 5 | set --local src recur.fish 6 | set --local dst $__fish_config_dir/completions/ 7 | 8 | printf 'copying "%s" to "%s"\n' $src $dst 9 | 10 | cp $src $dst 11 | -------------------------------------------------------------------------------- /completions/recur.bash: -------------------------------------------------------------------------------- 1 | _recur() { 2 | local cur prev opts 3 | 4 | COMPREPLY=() 5 | cur=${COMP_WORDS[COMP_CWORD]} 6 | prev=${COMP_WORDS[COMP_CWORD - 1]} 7 | opts='-h --help -V --version -a --attempts -b --backoff -c --condition -d --delay -f --forever -j --jitter -m --max-delay -t --timeout -v --verbose' 8 | 9 | case "${prev}" in 10 | -a | --attempts) 11 | COMPREPLY=($(compgen -W "10 -1" -- ${cur})) 12 | return 0 13 | ;; 14 | -b | --backoff) 15 | COMPREPLY=($(compgen -W "1.1s 2s" -- ${cur})) 16 | return 0 17 | ;; 18 | -c | --condition) 19 | COMPREPLY=($(compgen -W "'code==0' 'code!=0'" -- ${cur})) 20 | return 0 21 | ;; 22 | -d | --delay | -m | --max-delay | -t | --timeout) 23 | COMPREPLY=($(compgen -W "1s 5s 30s 1m 5m" -- ${cur})) 24 | return 0 25 | ;; 26 | -j | --jitter) 27 | COMPREPLY=($(compgen -W "1s 1s,5s 1m" -- ${cur})) 28 | return 0 29 | ;; 30 | esac 31 | 32 | if [[ ${cur} == -* ]]; then 33 | COMPREPLY=($(compgen -W "${opts}" -- ${cur})) 34 | return 0 35 | fi 36 | 37 | # Complete with commands if no other completion applies. 38 | COMPREPLY=($(compgen -c -- "${cur}")) 39 | } 40 | 41 | complete -F _recur recur 42 | -------------------------------------------------------------------------------- /completions/recur.fish: -------------------------------------------------------------------------------- 1 | complete -c recur -s h -l help -d "Print help message and exit" 2 | complete -c recur -s V -l version -d "Print version number and exit" 3 | complete -c recur -s a -l attempts -x -d "Maximum number of attempts" -a "10 -1" 4 | complete -c recur -s b -l backoff -x -d "Base for exponential backoff" -a "0 1.1s 2s" 5 | complete -c recur -s c -l condition -x -d "Success condition" -a "'code == 0' 'code != 0'" 6 | complete -c recur -s d -l delay -x -d "Constant delay" -a "1s 5s 30s 1m 5m" 7 | complete -c recur -s f -l forever -d "Infinite attempts" 8 | complete -c recur -s j -l jitter -x -d "Additional random delay" -a "1s 1s,5s 1m" 9 | complete -c recur -s m -l max-delay -x -d "Maximum allowed delay" -a "1s 5s 30s 1m 5m" 10 | complete -c recur -s t -l timeout -x -d "Timeout for each attempt" -a "1s 5s 30s 1m 5m" 11 | complete -c recur -s v -l verbose -d "Increase verbosity" 12 | 13 | # Complete with available commands. 14 | complete -c recur -n "not __fish_seen_subcommand_from (__fish_complete_command)" -a "(__fish_complete_command)" 15 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module dbohdan.com/recur/v2 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/alecthomas/repr v0.4.0 7 | github.com/kopoli/go-terminal-size v0.0.0-20170219200355-5c97524c8b54 8 | github.com/mitchellh/go-wordwrap v1.0.1 9 | go.starlark.net v0.0.0-20240925182052-1207426daebd 10 | ) 11 | 12 | require ( 13 | github.com/mattn/go-isatty v0.0.20 // indirect 14 | golang.org/x/sys v0.6.0 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 2 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 3 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 4 | github.com/kopoli/go-terminal-size v0.0.0-20170219200355-5c97524c8b54 h1:0SMHxjkLKNawqUjjnMlCtEdj6uWZjv0+qDZ3F6GOADI= 5 | github.com/kopoli/go-terminal-size v0.0.0-20170219200355-5c97524c8b54/go.mod h1:bm7MVZZvHQBfqHG5X59jrRE/3ak6HvK+/Zb6aZhLR2s= 6 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 7 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 8 | github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= 9 | github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= 10 | go.starlark.net v0.0.0-20240925182052-1207426daebd h1:S+EMisJOHklQxnS3kqsY8jl2y5aF0FDEdcLnOw3q22E= 11 | go.starlark.net v0.0.0-20240925182052-1207426daebd/go.mod h1:YKMCv9b1WrfWmeqdV5MAuEHWsu5iC+fe6kYl2sQjdI8= 12 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 13 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 14 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 15 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023-2025 D. Bohdan 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | // THE SOFTWARE. 20 | 21 | package main 22 | 23 | import ( 24 | "context" 25 | "errors" 26 | "fmt" 27 | "io" 28 | "log" 29 | "math" 30 | "math/rand" 31 | "os" 32 | "os/exec" 33 | "path/filepath" 34 | "regexp" 35 | "strconv" 36 | "strings" 37 | "time" 38 | 39 | "github.com/alecthomas/repr" 40 | tsize "github.com/kopoli/go-terminal-size" 41 | "github.com/mitchellh/go-wordwrap" 42 | "go.starlark.net/starlark" 43 | ) 44 | 45 | const ( 46 | envVarAttempt = "RECUR_ATTEMPT" 47 | envVarMaxAttempts = "RECUR_MAX_ATTEMPTS" 48 | envVarAttemptSinceReset = "RECUR_ATTEMPT_SINCE_RESET" 49 | exitCodeCommandNotFound = 255 50 | exitCodeError = -1 51 | maxVerboseLevel = 3 52 | version = "2.3.0" 53 | ) 54 | 55 | type attempt struct { 56 | CommandFound bool 57 | Duration time.Duration 58 | ExitCode int 59 | MaxAttempts int 60 | Number int 61 | NumberSinceReset int 62 | TotalTime time.Duration 63 | } 64 | 65 | type interval struct { 66 | Start time.Duration 67 | End time.Duration 68 | } 69 | 70 | type commandStatus int 71 | 72 | const ( 73 | statusFinished commandStatus = iota 74 | statusTimeout 75 | statusNotFound 76 | statusUnknownError 77 | ) 78 | 79 | type commandResult struct { 80 | Status commandStatus 81 | ExitCode int 82 | } 83 | 84 | type retryConfig struct { 85 | Command string 86 | Args []string 87 | Backoff time.Duration 88 | Condition string 89 | Fibonacci bool 90 | FixedDelay interval 91 | MaxAttempts int 92 | RandomDelay interval 93 | Reset time.Duration 94 | Timeout time.Duration 95 | Verbose int 96 | } 97 | 98 | const ( 99 | backoffDefault = time.Duration(0) 100 | conditionDefault = "code == 0" 101 | delayDefault = time.Duration(0) 102 | jitterDefault = "0,0" 103 | maxDelayDefault = time.Duration(time.Hour) 104 | maxAttemptsDefault = 10 105 | resetDefault = time.Duration(-time.Second) 106 | timeoutDefault = time.Duration(-time.Second) 107 | ) 108 | 109 | type elapsedTimeWriter struct { 110 | startTime time.Time 111 | } 112 | 113 | type exitRequestError struct { 114 | Code int 115 | } 116 | 117 | func (w *elapsedTimeWriter) Write(bytes []byte) (int, error) { 118 | elapsed := time.Since(w.startTime) 119 | 120 | hours := int(elapsed.Hours()) 121 | minutes := int(elapsed.Minutes()) % 60 122 | seconds := int(elapsed.Seconds()) % 60 123 | deciseconds := elapsed.Milliseconds() % 1000 / 100 124 | 125 | return fmt.Fprintf(os.Stderr, "recur [%02d:%02d:%02d.%01d]: %s", hours, minutes, seconds, deciseconds, string(bytes)) 126 | } 127 | 128 | func (e *exitRequestError) Error() string { 129 | return fmt.Sprintf("exit requested with code %d", e.Code) 130 | } 131 | 132 | func StarlarkExit(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { 133 | var code starlark.Value 134 | if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &code); err != nil { 135 | return nil, err 136 | } 137 | 138 | if _, ok := code.(starlark.NoneType); ok { 139 | return starlark.None, &exitRequestError{Code: int(exitCodeCommandNotFound)} 140 | } 141 | 142 | if codeInt, ok := code.(starlark.Int); ok { 143 | exitCode, ok := codeInt.Int64() 144 | if !ok { 145 | return nil, fmt.Errorf("exit code too large") 146 | } 147 | 148 | return starlark.None, &exitRequestError{Code: int(exitCode)} 149 | } 150 | 151 | return nil, fmt.Errorf("exit code wasn't 'int' or 'None'") 152 | } 153 | 154 | func StarlarkInspect(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { 155 | var prefix starlark.String 156 | var value starlark.Value 157 | 158 | if err := starlark.UnpackArgs(b.Name(), args, kwargs, "value", &value, "prefix?", &prefix); err != nil { 159 | return nil, err 160 | } 161 | 162 | prefixStr := "" 163 | if prefix.Len() > 0 { 164 | prefixStr = prefix.GoString() 165 | } 166 | 167 | log.Printf("inspect: %s%v\n", prefixStr, value) 168 | 169 | return value, nil 170 | } 171 | 172 | func parseInterval(s string) (interval, error) { 173 | var start, end time.Duration 174 | var err error 175 | 176 | parts := strings.Split(s, ",") 177 | if len(parts) == 2 { 178 | start, err = time.ParseDuration(parts[0]) 179 | if err != nil { 180 | return interval{}, fmt.Errorf("invalid start duration: %s", parts[0]) 181 | } 182 | end, err = time.ParseDuration(parts[1]) 183 | if err != nil { 184 | return interval{}, fmt.Errorf("invalid end duration: %s", parts[1]) 185 | } 186 | } else if len(parts) == 1 { 187 | end, err = time.ParseDuration(parts[0]) 188 | if err != nil { 189 | return interval{}, fmt.Errorf("invalid end duration: %s", parts[0]) 190 | } 191 | start = 0 192 | } else { 193 | return interval{}, fmt.Errorf("invalid interval format: %s", s) 194 | } 195 | 196 | if start < 0 || end < 0 || start > end { 197 | return interval{}, fmt.Errorf("invalid interval values: start=%s, end=%s", start.String(), end.String()) 198 | } 199 | 200 | return interval{Start: start, End: end}, nil 201 | } 202 | 203 | func evaluateCondition(attemptInfo attempt, expr string) (bool, error) { 204 | thread := &starlark.Thread{Name: "condition"} 205 | 206 | var code starlark.Value 207 | if attemptInfo.CommandFound { 208 | code = starlark.MakeInt(attemptInfo.ExitCode) 209 | } else { 210 | code = starlark.None 211 | } 212 | 213 | globals := starlark.StringDict{ 214 | "exit": starlark.NewBuiltin("exit", StarlarkExit), 215 | "inspect": starlark.NewBuiltin("inspect", StarlarkInspect), 216 | 217 | "attempt": starlark.MakeInt(attemptInfo.Number), 218 | "attempt_since_reset": starlark.MakeInt(attemptInfo.NumberSinceReset), 219 | "code": code, 220 | "command_found": starlark.Bool(attemptInfo.CommandFound), 221 | "max_attempts": starlark.MakeInt(attemptInfo.MaxAttempts), 222 | "time": starlark.Float(float64(attemptInfo.Duration) / float64(time.Second)), 223 | "total_time": starlark.Float(float64(attemptInfo.TotalTime) / float64(time.Second)), 224 | } 225 | 226 | val, err := starlark.Eval(thread, "", expr, globals) 227 | if err != nil { 228 | var exitErr *exitRequestError 229 | if errors.As(err, &exitErr) { 230 | return false, exitErr 231 | } 232 | 233 | return false, err 234 | } 235 | 236 | if val.Type() != "bool" { 237 | return false, fmt.Errorf("condition must return a boolean, got %s", val.Type()) 238 | } 239 | 240 | return bool(val.Truth()), nil 241 | } 242 | 243 | func executeCommand(command string, args []string, timeout time.Duration, envVars []string) commandResult { 244 | if _, err := exec.LookPath(command); err != nil { 245 | return commandResult{ 246 | Status: statusNotFound, 247 | ExitCode: exitCodeCommandNotFound, 248 | } 249 | } 250 | 251 | ctx := context.Background() 252 | if timeout >= 0 { 253 | var cancel context.CancelFunc 254 | ctx, cancel = context.WithTimeout(ctx, timeout) 255 | defer cancel() 256 | } 257 | 258 | cmd := exec.CommandContext(ctx, command, args...) 259 | cmd.Stdout = os.Stdout 260 | cmd.Stderr = os.Stderr 261 | cmd.Stdin = os.Stdin 262 | cmd.Env = append(os.Environ(), envVars...) 263 | 264 | err := cmd.Run() 265 | if err != nil { 266 | if ctx.Err() == context.DeadlineExceeded { 267 | return commandResult{ 268 | Status: statusTimeout, 269 | ExitCode: exitCodeError, 270 | } 271 | } 272 | 273 | var exitErr *exec.ExitError 274 | if errors.As(err, &exitErr) { 275 | return commandResult{ 276 | Status: statusFinished, 277 | ExitCode: exitErr.ExitCode(), 278 | } 279 | } 280 | 281 | return commandResult{ 282 | Status: statusUnknownError, 283 | ExitCode: exitCodeError, 284 | } 285 | } 286 | 287 | return commandResult{ 288 | Status: statusFinished, 289 | ExitCode: cmd.ProcessState.ExitCode(), 290 | } 291 | } 292 | 293 | func fib(n int) float64 { 294 | nf := float64(n) 295 | return math.Round((math.Pow(math.Phi, nf) - math.Pow(-math.Phi, -nf)) * 0.4472135954999579) 296 | } 297 | 298 | func delayBeforeAttempt(attemptNum int, config retryConfig) time.Duration { 299 | if attemptNum == 1 { 300 | return 0 301 | } 302 | 303 | currFixed := config.FixedDelay.Start.Seconds() 304 | currFixed += math.Pow(config.Backoff.Seconds(), float64(attemptNum-1)) 305 | if config.Fibonacci { 306 | currFixed += fib(attemptNum - 1) 307 | } 308 | if currFixed > config.FixedDelay.End.Seconds() { 309 | currFixed = config.FixedDelay.End.Seconds() 310 | } 311 | 312 | currRandom := config.RandomDelay.Start.Seconds() + 313 | rand.Float64()*(config.RandomDelay.End-config.RandomDelay.Start).Seconds() 314 | 315 | return time.Duration((currFixed + currRandom) * float64(time.Second)) 316 | } 317 | 318 | func formatDuration(d time.Duration) string { 319 | d = d.Round(time.Millisecond) 320 | if d > time.Second { 321 | d = d.Round(100 * time.Millisecond) 322 | } 323 | 324 | zeroUnits := regexp.MustCompile("(^|[^0-9])(?:0h)?(?:0m)?(?:0s)?$") 325 | s := zeroUnits.ReplaceAllString(d.String(), "$1") 326 | 327 | if s == "" { 328 | return "0" 329 | } 330 | return s 331 | } 332 | 333 | func retry(config retryConfig) (int, error) { 334 | var cmdResult commandResult 335 | var startTime time.Time 336 | var totalTime time.Duration 337 | 338 | resetAttemptNum := 1 339 | for attemptNum := 1; config.MaxAttempts < 0 || attemptNum <= config.MaxAttempts; attemptNum++ { 340 | attemptSinceReset := attemptNum - resetAttemptNum + 1 341 | delay := delayBeforeAttempt(attemptSinceReset, config) 342 | if delay > 0 { 343 | if config.Verbose >= 1 { 344 | log.Printf("waiting %s after attempt %d", formatDuration(delay), attemptNum-1) 345 | } 346 | time.Sleep(delay) 347 | } 348 | 349 | attemptStart := time.Now() 350 | if startTime.IsZero() { 351 | startTime = attemptStart 352 | } 353 | 354 | envVars := []string{ 355 | fmt.Sprintf("%s=%d", envVarAttempt, attemptNum), 356 | fmt.Sprintf("%s=%d", envVarAttemptSinceReset, attemptSinceReset), 357 | fmt.Sprintf("%s=%d", envVarMaxAttempts, config.MaxAttempts), 358 | } 359 | cmdResult = executeCommand(config.Command, config.Args, config.Timeout, envVars) 360 | 361 | attemptEnd := time.Now() 362 | attemptDuration := attemptEnd.Sub(attemptStart) 363 | totalTime = attemptEnd.Sub(startTime) 364 | 365 | if config.Reset >= 0 && attemptDuration >= config.Reset { 366 | resetAttemptNum = attemptNum 367 | } 368 | 369 | if config.Verbose >= 1 { 370 | switch cmdResult.Status { 371 | case statusFinished: 372 | log.Printf("command exited with code %d on attempt %d", cmdResult.ExitCode, attemptNum) 373 | case statusTimeout: 374 | log.Printf("command timed out after %s on attempt %d", formatDuration(attemptDuration), attemptNum) 375 | case statusNotFound: 376 | log.Printf("command was not found on attempt %d", attemptNum) 377 | case statusUnknownError: 378 | log.Printf("unknown error occurred on attempt %d", attemptNum) 379 | } 380 | } 381 | 382 | attemptInfo := attempt{ 383 | CommandFound: cmdResult.Status != statusNotFound, 384 | Duration: attemptDuration, 385 | ExitCode: cmdResult.ExitCode, 386 | MaxAttempts: config.MaxAttempts, 387 | Number: attemptNum, 388 | NumberSinceReset: attemptSinceReset, 389 | TotalTime: totalTime, 390 | } 391 | 392 | success, err := evaluateCondition(attemptInfo, config.Condition) 393 | if err != nil { 394 | var exitErr *exitRequestError 395 | if errors.As(err, &exitErr) { 396 | return exitErr.Code, nil 397 | } 398 | 399 | return 1, fmt.Errorf("condition evaluation failed: %w", err) 400 | } 401 | 402 | if success { 403 | return cmdResult.ExitCode, nil 404 | } 405 | 406 | if config.Verbose >= 2 { 407 | log.Printf("condition not met; continuing to next attempt") 408 | } 409 | } 410 | 411 | return cmdResult.ExitCode, fmt.Errorf("maximum %d attempts reached", config.MaxAttempts) 412 | } 413 | 414 | func wrapForTerm(s string) string { 415 | size, err := tsize.GetSize() 416 | if err != nil { 417 | return s 418 | } 419 | 420 | return wordwrap.WrapString(s, uint(size.Width)) 421 | } 422 | 423 | func usage(w io.Writer) { 424 | s := fmt.Sprintf( 425 | `Usage: %s [-h] [-V] [-a ] [-b ] [-c ] [-d ] [-F] [-f] [-j ] [-m ] [-r ] [-t ] [-v] [--] [ ...]`, 426 | filepath.Base(os.Args[0]), 427 | ) 428 | 429 | fmt.Fprintln(w, wrapForTerm(s)) 430 | } 431 | 432 | func help() { 433 | usage(os.Stdout) 434 | 435 | s := fmt.Sprintf( 436 | ` 437 | Retry a command with exponential backoff and jitter. 438 | 439 | Arguments: 440 | 441 | Command to run 442 | 443 | [ ...] 444 | Arguments to the command 445 | 446 | Options: 447 | -h, --help 448 | Print this help message and exit 449 | 450 | -V, --version 451 | Print version number and exit 452 | 453 | -a, --attempts %v 454 | Maximum number of attempts (negative for infinite) 455 | 456 | -b, --backoff %v 457 | Base for exponential backoff (duration) 458 | 459 | -c, --condition '%v' 460 | Success condition (Starlark expression) 461 | 462 | -d, --delay %v 463 | Constant delay (duration) 464 | 465 | -F, --fib 466 | Add Fibonacci backoff 467 | 468 | -f, --forever 469 | Infinite attempts 470 | 471 | -j, --jitter '%v' 472 | Additional random delay (maximum duration or 'min,max' duration) 473 | 474 | -m, --max-delay %v 475 | Maximum allowed sum of constant delay, exponential backoff, and Fibonacci backoff (duration) 476 | 477 | -r, --reset %v 478 | Minimum attempt time that resets exponential and Fibonacci backoff (duration; negative for no reset) 479 | 480 | -t, --timeout %v 481 | Timeout for each attempt (duration; negative for no timeout) 482 | 483 | -v, --verbose 484 | Increase verbosity (up to %v times) 485 | `, 486 | maxAttemptsDefault, 487 | formatDuration(backoffDefault), 488 | conditionDefault, 489 | formatDuration(delayDefault), 490 | jitterDefault, 491 | formatDuration(maxDelayDefault), 492 | formatDuration(resetDefault), 493 | formatDuration(timeoutDefault), 494 | maxVerboseLevel, 495 | ) 496 | 497 | fmt.Print(wrapForTerm(s)) 498 | } 499 | 500 | func parseArgs() retryConfig { 501 | config := retryConfig{ 502 | Args: []string{}, 503 | Backoff: backoffDefault, 504 | Condition: conditionDefault, 505 | FixedDelay: interval{Start: delayDefault, End: maxDelayDefault}, 506 | MaxAttempts: maxAttemptsDefault, 507 | Reset: resetDefault, 508 | Timeout: timeoutDefault, 509 | } 510 | 511 | usageError := func(message string, badValue interface{}) { 512 | usage(os.Stderr) 513 | fmt.Fprintf(os.Stderr, "\nError: "+message+"\n", badValue) 514 | os.Exit(2) 515 | } 516 | 517 | vShortFlags := regexp.MustCompile("^-v+$") 518 | 519 | // Parse the command-line options. 520 | var i int 521 | printHelp := false 522 | printVersion := false 523 | 524 | nextArg := func(flag string) string { 525 | i++ 526 | 527 | if i >= len(os.Args) { 528 | usageError("no value for option: %s", flag) 529 | } 530 | 531 | return os.Args[i] 532 | } 533 | 534 | for i = 1; i < len(os.Args); i++ { 535 | arg := os.Args[i] 536 | 537 | if arg == "--" { 538 | i++ 539 | break 540 | } 541 | if !strings.HasPrefix(arg, "-") { 542 | break 543 | } 544 | 545 | switch arg { 546 | case "-a", "--attempts": 547 | value := nextArg(arg) 548 | 549 | var maxAttempts int 550 | maxAttempts, err := strconv.Atoi(value) 551 | if err != nil { 552 | usageError("invalid maximum number of attempts: %v", value) 553 | } 554 | 555 | config.MaxAttempts = maxAttempts 556 | 557 | case "-b", "--backoff": 558 | value := nextArg(arg) 559 | 560 | backoff, err := time.ParseDuration(value) 561 | if err != nil { 562 | usageError("invalid backoff: %v", value) 563 | } 564 | 565 | config.Backoff = backoff 566 | 567 | case "-c", "--condition": 568 | config.Condition = nextArg(arg) 569 | 570 | case "-d", "--delay": 571 | value := nextArg(arg) 572 | 573 | delay, err := time.ParseDuration(value) 574 | if err != nil { 575 | usageError("invalid delay: %v", value) 576 | } 577 | 578 | config.FixedDelay.Start = delay 579 | if config.FixedDelay.End < config.FixedDelay.Start { 580 | config.FixedDelay.End = config.FixedDelay.Start 581 | } 582 | 583 | case "-F", "--fib": 584 | config.Fibonacci = true 585 | 586 | case "-f", "--forever": 587 | config.MaxAttempts = -1 588 | 589 | case "-h", "--help": 590 | printHelp = true 591 | 592 | case "-j", "--jitter": 593 | jitter, err := parseInterval(nextArg(arg)) 594 | if err != nil { 595 | usageError("invalid jitter: %v", err) 596 | } 597 | 598 | config.RandomDelay = jitter 599 | 600 | case "-m", "--max-delay": 601 | value := nextArg(arg) 602 | 603 | maxDelay, err := time.ParseDuration(value) 604 | if err != nil { 605 | usageError("invalid maximum delay: %v", value) 606 | } 607 | 608 | config.FixedDelay.End = maxDelay 609 | 610 | case "-r", "--reset": 611 | value := nextArg(arg) 612 | 613 | reset, err := time.ParseDuration(value) 614 | if err != nil { 615 | usageError("invalid reset time: %v", value) 616 | } 617 | 618 | config.Reset = reset 619 | 620 | case "-t", "--timeout": 621 | value := nextArg(arg) 622 | 623 | timeout, err := time.ParseDuration(value) 624 | if err != nil { 625 | usageError("invalid timeout: %v", value) 626 | } 627 | 628 | config.Timeout = timeout 629 | 630 | // "-v" is handled in the default case. 631 | case "--verbose": 632 | config.Verbose++ 633 | 634 | case "-V", "--version": 635 | printVersion = true 636 | 637 | default: 638 | if vShortFlags.MatchString(arg) { 639 | config.Verbose += len(arg) - 1 640 | continue 641 | } 642 | 643 | usageError("unknown option: %v", arg) 644 | } 645 | } 646 | 647 | if printHelp { 648 | help() 649 | os.Exit(0) 650 | } 651 | 652 | if printVersion { 653 | fmt.Printf("%s\n", version) 654 | os.Exit(0) 655 | } 656 | 657 | if config.Verbose > maxVerboseLevel { 658 | usageError("up to %d verbose options is allowed", maxVerboseLevel) 659 | } 660 | 661 | if i >= len(os.Args) { 662 | usageError(" is required%v", "") 663 | } 664 | 665 | config.Command = os.Args[i] 666 | config.Args = os.Args[i+1:] 667 | 668 | return config 669 | } 670 | 671 | func main() { 672 | config := parseArgs() 673 | 674 | // Configure logging. 675 | customWriter := &elapsedTimeWriter{ 676 | startTime: time.Now(), 677 | } 678 | log.SetOutput(customWriter) 679 | log.SetFlags(0) 680 | 681 | if config.Verbose >= 3 { 682 | log.Printf("configuration:\n%s\n", repr.String(config, repr.Indent("\t"), repr.OmitEmpty(false))) 683 | } 684 | 685 | exitCode, err := retry(config) 686 | if err != nil { 687 | log.Printf("%v", err) 688 | } 689 | 690 | os.Exit(exitCode) 691 | } 692 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2023-2025 D. Bohdan 2 | // 3 | // Permission is hereby granted, free of charge, to any person obtaining a copy 4 | // of this software and associated documentation files (the "Software"), to deal 5 | // in the Software without restriction, including without limitation the rights 6 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | // copies of the Software, and to permit persons to whom the Software is 8 | // furnished to do so, subject to the following conditions: 9 | // 10 | // The above copyright notice and this permission notice shall be included in 11 | // all copies or substantial portions of the Software. 12 | // 13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | // THE SOFTWARE. 20 | 21 | package main 22 | 23 | import ( 24 | "bytes" 25 | "os/exec" 26 | "regexp" 27 | "strings" 28 | "testing" 29 | ) 30 | 31 | var ( 32 | commandEnv = "test/env" 33 | commandExit99 = "test/exit99" 34 | commandHello = "test/hello" 35 | commandRecur = "./recur" 36 | commandSleep = "test/sleep" 37 | noSuchCommand = "no-such-command-should-exist" 38 | ) 39 | 40 | func runCommand(args ...string) (string, string, error) { 41 | cmd := exec.Command(commandRecur, args...) 42 | var stdout, stderr bytes.Buffer 43 | cmd.Stdout = &stdout 44 | cmd.Stderr = &stderr 45 | err := cmd.Run() 46 | 47 | return stdout.String(), stderr.String(), err 48 | } 49 | 50 | func TestUsage(t *testing.T) { 51 | _, stderr, _ := runCommand() 52 | 53 | if matched, _ := regexp.MatchString("Usage", stderr); !matched { 54 | t.Error("Expected 'Usage' in stderr") 55 | } 56 | } 57 | 58 | func TestVersion(t *testing.T) { 59 | stdout, _, _ := runCommand("--version") 60 | 61 | if matched, _ := regexp.MatchString(`\d+\.\d+\.\d+`, stdout); !matched { 62 | t.Error("Expected version format in stdout") 63 | } 64 | } 65 | 66 | func TestUnknownOptBeforeHelp(t *testing.T) { 67 | _, _, err := runCommand("--foo", "--help", commandExit99) 68 | 69 | if exitErr, ok := err.(*exec.ExitError); !ok || exitErr.ExitCode() != 2 { 70 | t.Errorf("Expected exit status 2, got %v", err) 71 | } 72 | } 73 | 74 | func TestUnknownOptAfterHelp(t *testing.T) { 75 | _, _, err := runCommand("--help", "--foo", commandExit99) 76 | 77 | if exitErr, ok := err.(*exec.ExitError); !ok || exitErr.ExitCode() != 2 { 78 | t.Errorf("Expected exit status 2, got %v", err) 79 | } 80 | } 81 | 82 | func TestEcho(t *testing.T) { 83 | stdout, _, _ := runCommand(commandHello) 84 | 85 | if matched, _ := regexp.MatchString("hello", stdout); !matched { 86 | t.Error("Expected 'hello' in stdout") 87 | } 88 | } 89 | 90 | func TestEnv(t *testing.T) { 91 | _, _, err := runCommand(commandEnv) 92 | 93 | if _, ok := err.(*exec.ExitError); ok { 94 | t.Errorf("Expected exit status 0, got %v", err) 95 | } 96 | } 97 | 98 | func TestExitCode(t *testing.T) { 99 | _, _, err := runCommand(commandExit99) 100 | 101 | if exitErr, ok := err.(*exec.ExitError); !ok || exitErr.ExitCode() != 99 { 102 | t.Errorf("Expected exit status 99, got %v", err) 103 | } 104 | } 105 | 106 | func TestCommandNotFound(t *testing.T) { 107 | _, _, err := runCommand(noSuchCommand) 108 | 109 | if exitErr, ok := err.(*exec.ExitError); !ok || exitErr.ExitCode() != 255 { 110 | t.Errorf("Expected exit status 255, got %v", err) 111 | } 112 | } 113 | 114 | func TestOptions(t *testing.T) { 115 | _, _, err := runCommand("-a", "0", "-b", "1s", "-d", "0", "-F", "--jitter", "0,0.1s", "-m", "0", commandHello) 116 | 117 | if err != nil { 118 | t.Errorf("Expected no error, got %v", err) 119 | } 120 | } 121 | 122 | func TestEndOfOptions(t *testing.T) { 123 | _, _, err := runCommand("--", commandHello) 124 | 125 | if err != nil { 126 | t.Errorf("Expected no error, got %v", err) 127 | } 128 | } 129 | 130 | func TestEndOfOptionsHelp(t *testing.T) { 131 | _, _, err := runCommand("--", commandExit99, "--help") 132 | 133 | if exitErr, ok := err.(*exec.ExitError); !ok || exitErr.ExitCode() != 99 { 134 | t.Errorf("Expected exit status 99, got %v", err) 135 | } 136 | } 137 | 138 | func TestAttemptsTrailingGarbageOptions(t *testing.T) { 139 | _, _, err := runCommand("-a", "0abcdef", commandHello) 140 | 141 | if exitErr, ok := err.(*exec.ExitError); !ok || exitErr.ExitCode() != 2 { 142 | t.Errorf("Expected exit status 2, got %v", err) 143 | } 144 | } 145 | 146 | func TestBackoffAndNegativeDelay(t *testing.T) { 147 | _, stderr, _ := runCommand("-a", "2", "-b", "1.05s", "-d", "-1s", "-v", commandExit99) 148 | 149 | if matched, _ := regexp.MatchString(`waiting \d{2}ms`, stderr); !matched { 150 | t.Error(`Expected 'waiting \d{2}ms' in stderr`) 151 | } 152 | } 153 | 154 | func TestFibonacciBackoff(t *testing.T) { 155 | _, stderr, _ := runCommand("-d", "-33.99s", "-F", "-v", commandExit99) 156 | 157 | if matched, _ := regexp.MatchString(`waiting 10ms after attempt 9\n`, stderr); !matched { 158 | t.Error(`Expected 'waiting 10ms after attempt 9\n' in stderr`) 159 | } 160 | } 161 | 162 | func TestVerbose(t *testing.T) { 163 | _, stderr, _ := runCommand("-v", "-a", "3", commandExit99) 164 | 165 | if count := len(regexp.MustCompile("command exited with code").FindAllString(stderr, -1)); count != 3 { 166 | t.Errorf("Expected 3 instances of 'command exited with code', got %d", count) 167 | } 168 | 169 | if !strings.Contains(stderr, "on attempt 3\n") { 170 | t.Error("Expected 'on attempt 3' in stderr") 171 | } 172 | } 173 | 174 | func TestVerboseCommandNotFound(t *testing.T) { 175 | _, stderr, _ := runCommand("-v", "-a", "3", noSuchCommand) 176 | 177 | if count := len(regexp.MustCompile("command was not found").FindAllString(stderr, -1)); count != 3 { 178 | t.Errorf("Expected 3 instances of 'command was not found', got %d", count) 179 | } 180 | } 181 | 182 | func TestVerboseConfig(t *testing.T) { 183 | _, stderr, _ := runCommand("-vv", "--verbose", commandHello) 184 | 185 | if matched, _ := regexp.MatchString(`main\.retryConfig{\n`, stderr); !matched { 186 | t.Error(`Expected 'main\.retryConfig{\n' in stderr`) 187 | } 188 | } 189 | 190 | func TestVerboseTooMany(t *testing.T) { 191 | _, stderr, _ := runCommand("-vvvvvv", "") 192 | 193 | if matched, _ := regexp.MatchString("Error:.*?verbose options", stderr); !matched { 194 | t.Error("Expected 'Error:.*?verbose options' in stderr") 195 | } 196 | } 197 | 198 | func TestStopOnSuccess(t *testing.T) { 199 | stdout, _, _ := runCommand(commandHello) 200 | 201 | if count := len(regexp.MustCompile("hello").FindAllString(stdout, -1)); count != 1 { 202 | t.Errorf("Expected 1 instance of 'hello', got %d", count) 203 | } 204 | } 205 | 206 | func TestConditionAttemptForever(t *testing.T) { 207 | stdout, _, _ := runCommand("--condition", "attempt == 5", "--forever", commandHello) 208 | 209 | if count := len(regexp.MustCompile("hello").FindAllString(stdout, -1)); count != 5 { 210 | t.Errorf("Expected 5 instances of 'hello', got %d", count) 211 | } 212 | } 213 | 214 | func TestConditionAttemptNegative(t *testing.T) { 215 | stdout, _, _ := runCommand("--attempts", "-1", "--condition", "attempt == 5", commandHello) 216 | 217 | if count := len(regexp.MustCompile("hello").FindAllString(stdout, -1)); count != 5 { 218 | t.Errorf("Expected 5 instances of 'hello', got %d", count) 219 | } 220 | } 221 | 222 | func TestConditionExitIfCode(t *testing.T) { 223 | _, _, err := runCommand("--condition", "exit(0) if code == 99 else 'fail'", commandExit99) 224 | 225 | if err != nil { 226 | t.Errorf("Expected no error, got %v", err) 227 | } 228 | } 229 | 230 | func TestConditionExitArgNone(t *testing.T) { 231 | _, _, err := runCommand("-c", "exit(None)", commandHello) 232 | 233 | if exitErr, ok := err.(*exec.ExitError); !ok || exitErr.ExitCode() != 255 { 234 | t.Errorf("Expected exit status 255, got %v", err) 235 | } 236 | } 237 | 238 | func TestConditionExitArgTooLarge(t *testing.T) { 239 | _, stderr, err := runCommand("--condition", "exit(10000000000000000000)", commandHello) 240 | 241 | if matched, _ := regexp.MatchString("code too large", stderr); !matched { 242 | t.Error("Expected 'code too large' in stderr") 243 | } 244 | 245 | if exitErr, ok := err.(*exec.ExitError); !ok || exitErr.ExitCode() != 1 { 246 | t.Errorf("Expected exit status 1, got %v", err) 247 | } 248 | } 249 | 250 | func TestConditionExitArgWrongType(t *testing.T) { 251 | _, stderr, err := runCommand("--condition", "exit('foo')", commandHello) 252 | 253 | if matched, _ := regexp.MatchString("exit code wasn't", stderr); !matched { 254 | t.Error("Expected \"exit code wasn't\" in stderr") 255 | } 256 | 257 | if exitErr, ok := err.(*exec.ExitError); !ok || exitErr.ExitCode() != 1 { 258 | t.Errorf("Expected exit status 1, got %v", err) 259 | } 260 | } 261 | 262 | func TestConditionInspect(t *testing.T) { 263 | _, stderr, err := runCommand("--condition", "inspect(code) == 99 and exit(0)", commandExit99) 264 | 265 | if err != nil { 266 | t.Errorf("Expected no error, got %v", err) 267 | } 268 | 269 | if matched, _ := regexp.MatchString("inspect: 99", stderr); !matched { 270 | t.Error("Expected 'inspect: 99' in stderr") 271 | } 272 | } 273 | 274 | func TestConditionInspectWithPrefix(t *testing.T) { 275 | _, stderr, err := runCommand("--condition", "inspect(code, prefix='code = ') == 99 and exit(0)", commandExit99) 276 | 277 | if err != nil { 278 | t.Errorf("Expected no error, got %v", err) 279 | } 280 | 281 | if matched, _ := regexp.MatchString("code = 99", stderr); !matched { 282 | t.Error("Expected 'code = 99' in stderr") 283 | } 284 | } 285 | 286 | func TestConditionTimeAndTotalTime(t *testing.T) { 287 | stdout, _, _ := runCommand("--condition", "total_time > time", commandSleep, "0.1") 288 | 289 | if count := len(regexp.MustCompile("T").FindAllString(stdout, -1)); count != 2 { 290 | t.Errorf("Expected 2 instances of 'T', got %d", count) 291 | } 292 | } 293 | 294 | func TestReset(t *testing.T) { 295 | _, stderr, err := runCommand("--backoff", "0.1s", "--condition", "attempt == 3", "--reset", "10ms", "--verbose", commandSleep, "0.01") 296 | if err != nil { 297 | t.Errorf("Expected no error, got %v", err) 298 | } 299 | 300 | if count := len(regexp.MustCompile("waiting 100ms").FindAllString(stderr, -1)); count != 2 { 301 | t.Errorf("Expected 2 instances of 'waiting 100ms', got %d", count) 302 | } 303 | } 304 | 305 | func TestConditionTotalTime(t *testing.T) { 306 | stdout, _, _ := runCommand("--condition", "total_time > 0.3", commandSleep, "0.1") 307 | 308 | if matched, _ := regexp.MatchString(`(?:T\s*){2,3}`, stdout); !matched { 309 | t.Error(`Expected '(?:T\s*){2,3}' in stdout`) 310 | } 311 | } 312 | 313 | func TestConditionCommandNotFound(t *testing.T) { 314 | _, _, err := runCommand("--condition", "command_found or exit(42)", noSuchCommand) 315 | 316 | if exitErr, ok := err.(*exec.ExitError); !ok || exitErr.ExitCode() != 42 { 317 | t.Errorf("Expected exit status 42, got %v", err) 318 | } 319 | } 320 | 321 | func TestConditionCommandNotFoundCode(t *testing.T) { 322 | _, _, err := runCommand("--condition", "code == None and exit(42)", noSuchCommand) 323 | 324 | if exitErr, ok := err.(*exec.ExitError); !ok || exitErr.ExitCode() != 42 { 325 | t.Errorf("Expected exit status 42, got %v", err) 326 | } 327 | } 328 | 329 | func TestConditionTimeout(t *testing.T) { 330 | _, stderr, _ := runCommand("--attempts", "3", "--timeout", "100ms", "--verbose", commandSleep, "1") 331 | 332 | if count := len(regexp.MustCompile("command timed out").FindAllString(stderr, -1)); count != 3 { 333 | t.Errorf("Expected 3 instances of 'command timed out', got %d", count) 334 | } 335 | } 336 | -------------------------------------------------------------------------------- /script/release.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "fmt" 7 | "io" 8 | "os" 9 | "os/exec" 10 | "path/filepath" 11 | ) 12 | 13 | const ( 14 | checksumFilename = "SHA256SUMS.txt" 15 | projectName = "recur" 16 | distDir = "dist" 17 | ) 18 | 19 | type BuildTarget struct { 20 | os string 21 | arch string 22 | } 23 | 24 | func main() { 25 | version := os.Getenv("VERSION") 26 | if version == "" { 27 | fmt.Fprintln(os.Stderr, "'VERSION' environment variable must be set") 28 | os.Exit(1) 29 | } 30 | 31 | releaseDir := filepath.Join(distDir, version) 32 | if err := os.MkdirAll(releaseDir, 0755); err != nil { 33 | fmt.Fprintf(os.Stderr, "Failed to create release directory: %v\n", err) 34 | os.Exit(1) 35 | } 36 | 37 | targets := []BuildTarget{ 38 | {"darwin", "amd64"}, 39 | {"darwin", "arm64"}, 40 | {"freebsd", "amd64"}, 41 | {"linux", "amd64"}, 42 | {"linux", "arm64"}, 43 | {"linux", "riscv64"}, 44 | {"netbsd", "amd64"}, 45 | {"openbsd", "amd64"}, 46 | {"windows", "386"}, 47 | {"windows", "amd64"}, 48 | {"windows", "arm64"}, 49 | } 50 | 51 | for _, target := range targets { 52 | if err := build(releaseDir, target, version); err != nil { 53 | fmt.Fprintf(os.Stderr, "Build failed for %s/%s: %v\n", target.os, target.arch, err) 54 | os.Exit(1) 55 | } 56 | } 57 | } 58 | 59 | func build(dir string, target BuildTarget, version string) error { 60 | fmt.Printf("Building for %s/%s\n", target.os, target.arch) 61 | 62 | ext := "" 63 | if target.os == "windows" { 64 | ext = ".exe" 65 | } 66 | 67 | // Map GOARCH and GOOS to user-facing names. 68 | arch := target.arch 69 | system := target.os 70 | 71 | if arch == "386" { 72 | arch = "x86" 73 | } 74 | if system == "darwin" { 75 | system = "macos" 76 | } 77 | if (system == "linux" || system == "macos") && arch == "amd64" { 78 | arch = "x86_64" 79 | } 80 | if system == "linux" && arch == "arm64" { 81 | arch = "aarch64" 82 | } 83 | 84 | filename := fmt.Sprintf("%s-v%s-%s-%s%s", projectName, version, system, arch, ext) 85 | outputPath := filepath.Join(dir, filename) 86 | 87 | cmd := exec.Command("go", "build", "-trimpath", "-o", outputPath, ".") 88 | cmd.Env = append(os.Environ(), 89 | "GOOS="+target.os, 90 | "GOARCH="+target.arch, 91 | "CGO_ENABLED=0", 92 | ) 93 | 94 | if output, err := cmd.CombinedOutput(); err != nil { 95 | return fmt.Errorf("Build command failed: %v\nOutput:\n%s", err, output) 96 | } 97 | 98 | return generateChecksum(outputPath, version) 99 | } 100 | 101 | func generateChecksum(filePath, version string) error { 102 | f, err := os.Open(filePath) 103 | if err != nil { 104 | return fmt.Errorf("Failed to open file for checksumming: %v", err) 105 | } 106 | defer f.Close() 107 | 108 | h := sha256.New() 109 | if _, err := io.Copy(h, f); err != nil { 110 | return fmt.Errorf("Failed to calculate hash: %v", err) 111 | } 112 | 113 | hash := hex.EncodeToString(h.Sum(nil)) 114 | 115 | checksumLine := fmt.Sprintf("%s %s\n", hash, filepath.Base(filePath)) 116 | 117 | checksumFilePath := filepath.Join(filepath.Dir(filePath), checksumFilename) 118 | f, err = os.OpenFile(checksumFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 119 | if err != nil { 120 | return fmt.Errorf("Failed to open checksum file: %v", err) 121 | } 122 | defer f.Close() 123 | 124 | if _, err := f.WriteString(checksumLine); err != nil { 125 | return fmt.Errorf("Failed to write checksum: %v", err) 126 | } 127 | 128 | return nil 129 | } 130 | -------------------------------------------------------------------------------- /script/render_template.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "text/template" 10 | 11 | "github.com/mitchellh/go-wordwrap" 12 | ) 13 | 14 | func main() { 15 | templateData, err := ioutil.ReadAll(os.Stdin) 16 | if err != nil { 17 | log.Fatalf("Failed to read template: %v", err) 18 | } 19 | 20 | cmd := exec.Command("./recur", "--help") 21 | var cmdOutput bytes.Buffer 22 | cmd.Stdout = &cmdOutput 23 | if err := cmd.Run(); err != nil { 24 | log.Fatalf("Failed to run command: %v", err) 25 | } 26 | 27 | funcMap := template.FuncMap{ 28 | "wrap": func(width uint, s string) (string, error) { 29 | return wordwrap.WrapString(s, width), nil 30 | }, 31 | } 32 | 33 | tmpl, err := template.New("template").Funcs(funcMap).Parse(string(templateData)) 34 | if err != nil { 35 | log.Fatalf("Failed to parse template: %v", err) 36 | } 37 | 38 | data := struct { 39 | Help string 40 | }{ 41 | Help: cmdOutput.String(), 42 | } 43 | 44 | if err := tmpl.Execute(os.Stdout, data); err != nil { 45 | log.Fatalf("Failed to execute template: %v", err) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /test/env.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | ) 8 | 9 | func main() { 10 | envRecurAttempt := os.Getenv("RECUR_ATTEMPT") 11 | attempt, err := strconv.Atoi(envRecurAttempt) 12 | if err != nil { 13 | fmt.Printf("%v\n", err) 14 | os.Exit(101) 15 | } 16 | 17 | envRecurMaxAttempts := os.Getenv("RECUR_MAX_ATTEMPTS") 18 | maxAttempts, err := strconv.Atoi(envRecurMaxAttempts) 19 | if err != nil { 20 | fmt.Printf("%v\n", err) 21 | os.Exit(102) 22 | } 23 | 24 | if attempt == 3 && maxAttempts == 10 { 25 | os.Exit(0) 26 | } else { 27 | os.Exit(103) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/exit99.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "os" 4 | 5 | func main() { 6 | os.Exit(99) 7 | } 8 | -------------------------------------------------------------------------------- /test/hello.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | func main() { 6 | fmt.Printf("hello\n") 7 | } 8 | -------------------------------------------------------------------------------- /test/sleep.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strconv" 7 | "time" 8 | ) 9 | 10 | func main() { 11 | if len(os.Args) < 2 { 12 | fmt.Fprintf(os.Stderr, "usage: %s seconds\n", os.Args[0]) 13 | os.Exit(2) 14 | } 15 | 16 | seconds, err := strconv.ParseFloat(os.Args[1], 64) 17 | if err != nil { 18 | fmt.Println("Invalid number of seconds:", err) 19 | return 20 | } 21 | 22 | time.Sleep(time.Duration(seconds * float64(time.Second))) 23 | fmt.Printf("T\n") 24 | } 25 | --------------------------------------------------------------------------------