Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ and versions are tracked in the repo-root `VERSION` file.
- Added portable stdlib temporary file and directory helpers with default exit
cleanup.
- Added stdlib command path and function introspection helpers.
- Added `std_run_with_timeout` for bounded command execution with macOS/Linux
fallback behavior.

## [0.2.1] - 2026-06-18

Expand Down
22 changes: 22 additions & 0 deletions lib/bash/std/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ The library improves Bash-based scripting in a few practical ways:
instead of a mysterious non-zero exit.
- **Safe command execution**: `std_run` preserves argument boundaries, supports
dry-run mode, and can either exit or return a status.
- **Bounded command execution**: `std_run_with_timeout` applies the same command
runner conventions with a timeout.
- **Shared dry-run behavior**: scripts do not need to reimplement "print what
would happen" logic.
- **Composable cleanup**: scripts can register exit cleanup without replacing
Expand Down Expand Up @@ -205,6 +207,26 @@ in the calling script so the code remains clear.
code should use `std_run` to avoid collisions with test frameworks and other
Bash libraries that define their own `run` helper.

Use `std_run_with_timeout` when a command must finish within a bounded number of
seconds:

```bash
std_run_with_timeout 30 curl -fsSL "$health_url"
```

It accepts the same initial `--no-exit` and `--quiet` options as `std_run`:

```bash
if ! std_run_with_timeout --no-exit --quiet 5 nc -z localhost 5432; then
log_warn "database port did not open within 5 seconds"
fi
```

Timeouts return status `124`. The helper prefers `timeout` or `gtimeout` when
available and otherwise uses a Bash fallback so scripts work on macOS and Linux.
As with `std_run`, command arguments are executed as an argument array and
dry-run mode logs without running the command.

## Importing Other Bash Libraries

Use `import` to source helper libraries:
Expand Down
123 changes: 123 additions & 0 deletions lib/bash/std/lib_std.sh
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
# Core helpers:
# std_run [--no-exit] [--quiet] cmd ...
# # Safe command runner with dry-run & failure handling.
# std_run_with_timeout [opts] seconds cmd ...
# # Safe command runner with a timeout.
# exit_if_error rc msg... # Log + exit when rc != 0 (preserves original status).
# fatal_error msg... # Convenience wrapper: exit with last status or 1.
# std_register_cleanup_hook fn # Run a cleanup function from the shared EXIT trap.
Expand Down Expand Up @@ -826,6 +828,127 @@ run() {
__std_run_impl__ run "$@"
}

__std_sleep_interval__() {
if [[ -x /bin/sleep ]]; then
/bin/sleep "$1"
else
sleep "$1"
fi
}

__std_run_with_timeout_fallback__() {
local timeout_seconds="$1"
shift
local timeout_marker command_pid timer_pid command_status

timeout_marker="$(mktemp "${TMPDIR:-/tmp}/base-bash-libs-timeout.XXXXXXXXXX" 2>/dev/null)" || return 127

"$@" &
command_pid=$!

(
__std_sleep_interval__ "$timeout_seconds"
printf '1' > "$timeout_marker"
kill -TERM "$command_pid" 2>/dev/null || true
) &
timer_pid=$!

wait "$command_pid"
command_status=$?

if kill -0 "$timer_pid" 2>/dev/null; then
kill "$timer_pid" 2>/dev/null || true
fi
wait "$timer_pid" 2>/dev/null || true

if [[ -s "$timeout_marker" ]]; then
command_status=124
fi
rm -f -- "$timeout_marker"

return "$command_status"
}

#
# std_run_with_timeout - Safely executes a command with a timeout.
#
# This helper mirrors `std_run` option handling while bounding the command
# runtime. It prefers `timeout` or `gtimeout` when available and otherwise uses
# a Bash fallback so callers have portable behavior on macOS and Linux.
#
# Usage:
# std_run_with_timeout [--no-exit] [--quiet] <seconds> command [arg1] ...
#
std_run_with_timeout() {
local exit_on_failure=1 quiet=0 timeout_seconds timeout_path="" exit_code printable_command message

while (($#)); do
case "${1-}" in
--no-exit)
exit_on_failure=0
shift
;;
--quiet)
quiet=1
shift
;;
--)
shift
break
;;
*)
break
;;
esac
done

if (($# < 2)); then
log_error "std_run_with_timeout: usage: std_run_with_timeout [--no-exit] [--quiet] <seconds> command [arg1] ..."
return 1
fi

timeout_seconds="$1"
shift
if [[ ! "$timeout_seconds" =~ ^[1-9][0-9]*$ ]]; then
log_error "std_run_with_timeout: timeout seconds must be a positive integer."
return 1
fi

printf -v printable_command "%q " "$@"
printable_command="${printable_command% }"

if is_dry_run; then
log_info "[DRY-RUN] Would run with ${timeout_seconds}s timeout: ${printable_command}"
return 0
fi

if std_command_path timeout_path timeout || std_command_path timeout_path gtimeout; then
"$timeout_path" "$timeout_seconds" "$@"
else
__std_run_with_timeout_fallback__ "$timeout_seconds" "$@"
fi
exit_code=$?

if ((exit_code)); then
if ((exit_code == 124)); then
message="Command timed out after ${timeout_seconds}s: ${printable_command}"
else
message="Command failed (exit $exit_code): ${printable_command}"
fi

if ((exit_on_failure)); then
exit_if_error "$exit_code" "$message"
else
if ((! quiet)); then
log_warn "$message (continuing)."
fi
return "$exit_code"
fi
fi

return 0
}

############################################## FILE AND DIRECTORY HANDLING ############################################

#
Expand Down
85 changes: 85 additions & 0 deletions lib/bash/std/tests/lib_std.bats
Original file line number Diff line number Diff line change
Expand Up @@ -888,6 +888,91 @@ EOF
[[ "$output" != *"after"* ]]
}

@test "std_run_with_timeout runs commands and preserves arguments" {
local output_file="$TEST_TMPDIR/timeout-output.txt"

std_run_with_timeout 5 bash -c 'printf "%s\n" "$1" > "$2"' _ "hello world" "$output_file"

[ "$(cat "$output_file")" = "hello world" ]
}

@test "std_run_with_timeout --no-exit returns 124 when the command times out" {
local stderr_file="$TEST_TMPDIR/timeout.err"
local rc

if std_run_with_timeout --no-exit --quiet 1 sleep 2 2>"$stderr_file"; then
rc=0
else
rc=$?
fi

[ "$rc" -eq 124 ]
[ ! -s "$stderr_file" ]
}

@test "std_run_with_timeout exits on command failure by default" {
local script="$TEST_TMPDIR/timeout-fail.sh"

create_script "$script" <<EOF
#!/usr/bin/env bash
source "$STDLIB_PATH"
std_run_with_timeout 5 bash -c 'exit 6'
echo "after"
EOF

bats_run bash "$script"

[ "$status" -eq 6 ]
[[ "$output" == *"Command failed (exit 6)"* ]]
[[ "$output" != *"after"* ]]
}

@test "std_run_with_timeout honors dry-run mode without executing the command" {
local target="$TEST_TMPDIR/timeout-dry-run.txt"
DRY_RUN=true

std_run_with_timeout 1 touch "$target"

[ "$?" -eq 0 ]
[ ! -e "$target" ]
}

@test "std_run_with_timeout falls back when timeout binaries are absent" {
local fake_bin="$TEST_TMPDIR/no-timeout-bin"
local output_file="$TEST_TMPDIR/timeout-fallback-output.txt"
local script="$TEST_TMPDIR/timeout-fallback.sh"

mkdir -p "$fake_bin"
ln -s "$(command -v mktemp)" "$fake_bin/mktemp"
ln -s "$(command -v sleep)" "$fake_bin/sleep"

create_script "$script" <<EOF
#!/usr/bin/env bash
source "$STDLIB_PATH"
PATH="$fake_bin"
std_run_with_timeout --no-exit 5 /bin/echo fallback > "$output_file"
EOF

bats_run bash "$script"

[ "$status" -eq 0 ]
[ "$(cat "$output_file")" = "fallback" ]
}

@test "std_run_with_timeout rejects invalid timeouts" {
local stderr_file="$TEST_TMPDIR/timeout-invalid.err"
local rc

if std_run_with_timeout --no-exit nope bash -c 'exit 0' 2>"$stderr_file"; then
rc=0
else
rc=$?
fi

[ "$rc" -eq 1 ]
[[ "$(cat "$stderr_file")" == *"std_run_with_timeout: timeout seconds must be a positive integer."* ]]
}

@test "run compatibility wrapper delegates to std_run behavior" {
local stderr_file="$TEST_TMPDIR/run-compat.err"
local rc
Expand Down
Loading