diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-05 16:22:32 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-05 16:22:32 +0300 |
| commit | 3d3c5fda1021206655783b101e85f0b41f3588f4 (patch) | |
| tree | 61c1048d91c6ed6e8bd5e97c786db7411b4b921a | |
| parent | 41aac9fb4aed365b8f0fc8ec09798268da5f036c (diff) | |
Add timeouts for generation tools
| -rw-r--r-- | README.md | 14 | ||||
| -rwxr-xr-x | bin/photoalbum | 54 | ||||
| -rw-r--r-- | src/photoalbum.default.conf | 4 | ||||
| -rwxr-xr-x | src/photoalbum.sh | 54 | ||||
| -rwxr-xr-x | tests/cli.sh | 88 | ||||
| -rwxr-xr-x | tests/helpers.sh | 29 |
6 files changed, 224 insertions, 19 deletions
@@ -68,8 +68,8 @@ If the file is missing, run `photoalbum --init` first. The config file is a Bash file with assignments such as `INCOMING_DIR`, `DIST_DIR`, `TEMPLATE_DIR`, `TITLE`, `HEIGHT`, `THUMBHEIGHT`, `MAXPREVIEWS`, -`IMAGE_JOBS`, `RANDOM_SEED`, `SHUFFLE`, `SPLASH_PAGE`, and -`TARBALL_INCLUDE`. +`IMAGE_JOBS`, `IMAGEMAGICK_TIMEOUT`, `RANDOM_SEED`, `SHUFFLE`, `SPLASH_PAGE`, +`TARBALL_INCLUDE`, and `TAR_TIMEOUT`. Before generating, `photoalbum` validates the loaded config and command-line overrides. It checks required values, positive integer settings, `yes`/`no` @@ -88,9 +88,9 @@ tarball filename uses `<timestamp>` as a placeholder so the output is stable. `--print-config` writes stable shell-style assignments to stdout in this order: `CONFIG_SOURCE`, `INCOMING_DIR`, `DIST_DIR`, `TEMPLATE_DIR`, `TITLE`, `HEIGHT`, -`THUMBHEIGHT`, `MAXPREVIEWS`, `IMAGE_JOBS`, `RANDOM_SEED`, `SHUFFLE`, -`SPLASH_PAGE`, -`TARBALL_INCLUDE`, `TARBALL_SUFFIX`, `TAR_OPTS`, and `ORIGINAL_BASEPATH`. +`THUMBHEIGHT`, `MAXPREVIEWS`, `IMAGE_JOBS`, `IMAGEMAGICK_TIMEOUT`, +`RANDOM_SEED`, `SHUFFLE`, `SPLASH_PAGE`, `TARBALL_INCLUDE`, +`TARBALL_SUFFIX`, `TAR_TIMEOUT`, `TAR_OPTS`, and `ORIGINAL_BASEPATH`. Scalar values use Bash `%q` quoting and `TAR_OPTS` is normalized to a Bash array assignment, so the output can be parsed by shell tooling. `--quiet` does not suppress this output, and `--verbose` does not add human-readable diagnostics to @@ -152,6 +152,10 @@ ImageMagick photo processing runs in parallel. The default is `IMAGE_JOBS=3`. Set `IMAGE_JOBS` in the config, or pass `--image-jobs N`, to tune the number of concurrent ImageMagick jobs. +Each ImageMagick command is bounded by `IMAGEMAGICK_TIMEOUT=60` seconds, and +tarball creation is bounded by `TAR_TIMEOUT=120` seconds. Set either config +value to a positive integer to adjust the limit for large images or archives. + ## Example usage 1. Run `photoalbum --init`. diff --git a/bin/photoalbum b/bin/photoalbum index 172309f..8f7f1bd 100755 --- a/bin/photoalbum +++ b/bin/photoalbum @@ -248,9 +248,9 @@ init_config() { imagemagick() { if command -v magick >/dev/null 2>&1; then - magick "$@" + run_with_timeout ImageMagick "$IMAGEMAGICK_TIMEOUT" magick "$@" elif command -v convert >/dev/null 2>&1; then - convert "$@" + run_with_timeout ImageMagick "$IMAGEMAGICK_TIMEOUT" convert "$@" else printf 'ERROR: ImageMagick is required; install magick or convert\n' >&2 return 127 @@ -259,14 +259,18 @@ imagemagick() { imagemagick_identify() { if command -v magick >/dev/null 2>&1; then - magick identify "$@" + run_with_timeout ImageMagick "$IMAGEMAGICK_TIMEOUT" \ + magick identify "$@" elif command -v identify >/dev/null 2>&1; then - identify "$@" + run_with_timeout ImageMagick "$IMAGEMAGICK_TIMEOUT" \ + identify "$@" elif command -v convert >/dev/null 2>&1; then if [[ "${1:-}" = '-verbose' && $# -eq 2 ]]; then - convert "$2" -verbose info: + run_with_timeout ImageMagick "$IMAGEMAGICK_TIMEOUT" \ + convert "$2" -verbose info: else - convert "$@" info: + run_with_timeout ImageMagick "$IMAGEMAGICK_TIMEOUT" \ + convert "$@" info: fi else printf 'ERROR: ImageMagick is required; install magick or convert\n' >&2 @@ -274,6 +278,31 @@ imagemagick_identify() { fi } +run_with_timeout() { + local -r description="$1"; shift + local -r seconds="$1"; shift + local -i status=0 + + if ! command -v timeout >/dev/null 2>&1; then + printf 'ERROR: timeout command is required to run %s\n' \ + "$description" >&2 + return 127 + fi + + if timeout "$seconds" "$@"; then + return + else + status=$? + fi + + if (( status == 124 )); then + printf 'ERROR: %s timed out after %s seconds\n' \ + "$description" "$seconds" >&2 + fi + + return "$status" +} + tarball() { local -r tarball_name="$1"; shift local base @@ -288,7 +317,8 @@ tarball() { "from $INCOMING_DIR" ( cd "$(dirname "$INCOMING_DIR")" - tar "${tar_opts[@]}" -f "$DIST_DIR/$tarball_name" "$base" + run_with_timeout tar "$TAR_TIMEOUT" \ + tar "${tar_opts[@]}" -f "$DIST_DIR/$tarball_name" "$base" ) } @@ -1870,11 +1900,13 @@ print_config() { print_shell_assignment THUMBHEIGHT "$THUMBHEIGHT" print_shell_assignment MAXPREVIEWS "$MAXPREVIEWS" print_shell_assignment IMAGE_JOBS "$IMAGE_JOBS" + print_shell_assignment IMAGEMAGICK_TIMEOUT "$IMAGEMAGICK_TIMEOUT" print_shell_assignment RANDOM_SEED "${RANDOM_SEED:-}" print_shell_assignment SHUFFLE "${SHUFFLE:-no}" print_shell_assignment SPLASH_PAGE "${SPLASH_PAGE:-yes}" print_shell_assignment TARBALL_INCLUDE "${TARBALL_INCLUDE:-no}" print_shell_assignment TARBALL_SUFFIX "${TARBALL_SUFFIX:-.tar}" + print_shell_assignment TAR_TIMEOUT "$TAR_TIMEOUT" print_shell_array_assignment TAR_OPTS "${tar_opts[@]}" print_shell_assignment ORIGINAL_BASEPATH "${ORIGINAL_BASEPATH:-}" } @@ -2065,12 +2097,14 @@ missing_config() { apply_config_defaults() { HEIGHT="${HEIGHT:-}" IMAGE_JOBS="${IMAGE_JOBS:-3}" + IMAGEMAGICK_TIMEOUT="${IMAGEMAGICK_TIMEOUT:-60}" ORIGINAL_BASEPATH="${ORIGINAL_BASEPATH:-}" RANDOM_SEED="${RANDOM_SEED:-}" SHUFFLE="${SHUFFLE:-no}" SPLASH_PAGE="${SPLASH_PAGE:-yes}" TARBALL_INCLUDE="${TARBALL_INCLUDE:-no}" TARBALL_SUFFIX="${TARBALL_SUFFIX:-.tar}" + TAR_TIMEOUT="${TAR_TIMEOUT:-120}" if ! declare -p TAR_OPTS >/dev/null 2>&1; then TAR_OPTS=(-c) fi @@ -2275,6 +2309,8 @@ validate_generation_config() { validate_positive_integer_config_var THUMBHEIGHT validate_positive_integer_config_var MAXPREVIEWS validate_positive_integer_config_var IMAGE_JOBS + validate_positive_integer_config_var IMAGEMAGICK_TIMEOUT + validate_positive_integer_config_var TAR_TIMEOUT validate_yes_no_config_var SHUFFLE validate_yes_no_config_var SPLASH_PAGE validate_yes_no_config_var TARBALL_INCLUDE @@ -2314,6 +2350,8 @@ validate_print_config() { validate_positive_integer_config_var THUMBHEIGHT validate_positive_integer_config_var MAXPREVIEWS validate_positive_integer_config_var IMAGE_JOBS + validate_positive_integer_config_var IMAGEMAGICK_TIMEOUT + validate_positive_integer_config_var TAR_TIMEOUT validate_yes_no_config_var SHUFFLE validate_yes_no_config_var SPLASH_PAGE validate_yes_no_config_var TARBALL_INCLUDE @@ -2440,6 +2478,8 @@ log_configured_action() { log_verbose "Effective output directory: ${DIST_DIR:-}" log_verbose "Effective template directory: ${TEMPLATE_DIR:-}" log_verbose "Effective image jobs: ${IMAGE_JOBS:-3}" + log_verbose "Effective ImageMagick timeout: ${IMAGEMAGICK_TIMEOUT:-60}s" + log_verbose "Effective tar timeout: ${TAR_TIMEOUT:-120}s" log_verbose "Effective splash page setting: ${SPLASH_PAGE:-yes}" log_verbose "Effective tarball setting: ${TARBALL_INCLUDE:-no}" } diff --git a/src/photoalbum.default.conf b/src/photoalbum.default.conf index 6733f19..6ce1f6d 100644 --- a/src/photoalbum.default.conf +++ b/src/photoalbum.default.conf @@ -9,6 +9,8 @@ HEIGHT=1200 MAXPREVIEWS=40 # Parallel ImageMagick jobs for photo, thumbnail, and blur processing. IMAGE_JOBS=3 +# Timeout in seconds for each ImageMagick command. +IMAGEMAGICK_TIMEOUT=60 # Randomly shuffle all previews. # SHUFFLE=yes # Generate a splash landing page at index.html. @@ -26,6 +28,8 @@ TEMPLATE_DIR=/usr/share/photoalbum/templates/default # Includes a .tar of the incoming dir in the dist, can be yes or no TARBALL_INCLUDE=yes TARBALL_SUFFIX=.tar +# Timeout in seconds for tar archive creation. +TAR_TIMEOUT=120 TAR_OPTS=(-c) # Some debugging options diff --git a/src/photoalbum.sh b/src/photoalbum.sh index 211b53c..f616af7 100755 --- a/src/photoalbum.sh +++ b/src/photoalbum.sh @@ -248,9 +248,9 @@ init_config() { imagemagick() { if command -v magick >/dev/null 2>&1; then - magick "$@" + run_with_timeout ImageMagick "$IMAGEMAGICK_TIMEOUT" magick "$@" elif command -v convert >/dev/null 2>&1; then - convert "$@" + run_with_timeout ImageMagick "$IMAGEMAGICK_TIMEOUT" convert "$@" else printf 'ERROR: ImageMagick is required; install magick or convert\n' >&2 return 127 @@ -259,14 +259,18 @@ imagemagick() { imagemagick_identify() { if command -v magick >/dev/null 2>&1; then - magick identify "$@" + run_with_timeout ImageMagick "$IMAGEMAGICK_TIMEOUT" \ + magick identify "$@" elif command -v identify >/dev/null 2>&1; then - identify "$@" + run_with_timeout ImageMagick "$IMAGEMAGICK_TIMEOUT" \ + identify "$@" elif command -v convert >/dev/null 2>&1; then if [[ "${1:-}" = '-verbose' && $# -eq 2 ]]; then - convert "$2" -verbose info: + run_with_timeout ImageMagick "$IMAGEMAGICK_TIMEOUT" \ + convert "$2" -verbose info: else - convert "$@" info: + run_with_timeout ImageMagick "$IMAGEMAGICK_TIMEOUT" \ + convert "$@" info: fi else printf 'ERROR: ImageMagick is required; install magick or convert\n' >&2 @@ -274,6 +278,31 @@ imagemagick_identify() { fi } +run_with_timeout() { + local -r description="$1"; shift + local -r seconds="$1"; shift + local -i status=0 + + if ! command -v timeout >/dev/null 2>&1; then + printf 'ERROR: timeout command is required to run %s\n' \ + "$description" >&2 + return 127 + fi + + if timeout "$seconds" "$@"; then + return + else + status=$? + fi + + if (( status == 124 )); then + printf 'ERROR: %s timed out after %s seconds\n' \ + "$description" "$seconds" >&2 + fi + + return "$status" +} + tarball() { local -r tarball_name="$1"; shift local base @@ -288,7 +317,8 @@ tarball() { "from $INCOMING_DIR" ( cd "$(dirname "$INCOMING_DIR")" - tar "${tar_opts[@]}" -f "$DIST_DIR/$tarball_name" "$base" + run_with_timeout tar "$TAR_TIMEOUT" \ + tar "${tar_opts[@]}" -f "$DIST_DIR/$tarball_name" "$base" ) } @@ -1870,11 +1900,13 @@ print_config() { print_shell_assignment THUMBHEIGHT "$THUMBHEIGHT" print_shell_assignment MAXPREVIEWS "$MAXPREVIEWS" print_shell_assignment IMAGE_JOBS "$IMAGE_JOBS" + print_shell_assignment IMAGEMAGICK_TIMEOUT "$IMAGEMAGICK_TIMEOUT" print_shell_assignment RANDOM_SEED "${RANDOM_SEED:-}" print_shell_assignment SHUFFLE "${SHUFFLE:-no}" print_shell_assignment SPLASH_PAGE "${SPLASH_PAGE:-yes}" print_shell_assignment TARBALL_INCLUDE "${TARBALL_INCLUDE:-no}" print_shell_assignment TARBALL_SUFFIX "${TARBALL_SUFFIX:-.tar}" + print_shell_assignment TAR_TIMEOUT "$TAR_TIMEOUT" print_shell_array_assignment TAR_OPTS "${tar_opts[@]}" print_shell_assignment ORIGINAL_BASEPATH "${ORIGINAL_BASEPATH:-}" } @@ -2065,12 +2097,14 @@ missing_config() { apply_config_defaults() { HEIGHT="${HEIGHT:-}" IMAGE_JOBS="${IMAGE_JOBS:-3}" + IMAGEMAGICK_TIMEOUT="${IMAGEMAGICK_TIMEOUT:-60}" ORIGINAL_BASEPATH="${ORIGINAL_BASEPATH:-}" RANDOM_SEED="${RANDOM_SEED:-}" SHUFFLE="${SHUFFLE:-no}" SPLASH_PAGE="${SPLASH_PAGE:-yes}" TARBALL_INCLUDE="${TARBALL_INCLUDE:-no}" TARBALL_SUFFIX="${TARBALL_SUFFIX:-.tar}" + TAR_TIMEOUT="${TAR_TIMEOUT:-120}" if ! declare -p TAR_OPTS >/dev/null 2>&1; then TAR_OPTS=(-c) fi @@ -2275,6 +2309,8 @@ validate_generation_config() { validate_positive_integer_config_var THUMBHEIGHT validate_positive_integer_config_var MAXPREVIEWS validate_positive_integer_config_var IMAGE_JOBS + validate_positive_integer_config_var IMAGEMAGICK_TIMEOUT + validate_positive_integer_config_var TAR_TIMEOUT validate_yes_no_config_var SHUFFLE validate_yes_no_config_var SPLASH_PAGE validate_yes_no_config_var TARBALL_INCLUDE @@ -2314,6 +2350,8 @@ validate_print_config() { validate_positive_integer_config_var THUMBHEIGHT validate_positive_integer_config_var MAXPREVIEWS validate_positive_integer_config_var IMAGE_JOBS + validate_positive_integer_config_var IMAGEMAGICK_TIMEOUT + validate_positive_integer_config_var TAR_TIMEOUT validate_yes_no_config_var SHUFFLE validate_yes_no_config_var SPLASH_PAGE validate_yes_no_config_var TARBALL_INCLUDE @@ -2440,6 +2478,8 @@ log_configured_action() { log_verbose "Effective output directory: ${DIST_DIR:-}" log_verbose "Effective template directory: ${TEMPLATE_DIR:-}" log_verbose "Effective image jobs: ${IMAGE_JOBS:-3}" + log_verbose "Effective ImageMagick timeout: ${IMAGEMAGICK_TIMEOUT:-60}s" + log_verbose "Effective tar timeout: ${TAR_TIMEOUT:-120}s" log_verbose "Effective splash page setting: ${SPLASH_PAGE:-yes}" log_verbose "Effective tarball setting: ${TARBALL_INCLUDE:-no}" } diff --git a/tests/cli.sh b/tests/cli.sh index e4a94a6..367cd1a 100755 --- a/tests/cli.sh +++ b/tests/cli.sh @@ -927,11 +927,13 @@ HEIGHT=1200 THUMBHEIGHT=300 MAXPREVIEWS=40 IMAGE_JOBS=3 +IMAGEMAGICK_TIMEOUT=60 RANDOM_SEED='' SHUFFLE=no SPLASH_PAGE=yes TARBALL_INCLUDE=yes TARBALL_SUFFIX=.tar +TAR_TIMEOUT=120 TAR_OPTS=( -c ) ORIGINAL_BASEPATH='' EOF @@ -1062,11 +1064,13 @@ HEIGHT=120 THUMBHEIGHT=30 MAXPREVIEWS=7 IMAGE_JOBS=3 +IMAGEMAGICK_TIMEOUT=60 RANDOM_SEED='' SHUFFLE=yes SPLASH_PAGE=yes TARBALL_INCLUDE=no TARBALL_SUFFIX=.tar +TAR_TIMEOUT=120 TAR_OPTS=( -c ) ORIGINAL_BASEPATH='' EOF @@ -1099,11 +1103,13 @@ HEIGHT=120 THUMBHEIGHT=30 MAXPREVIEWS=8 IMAGE_JOBS=3 +IMAGEMAGICK_TIMEOUT=60 RANDOM_SEED='' SHUFFLE=no SPLASH_PAGE=yes TARBALL_INCLUDE=no TARBALL_SUFFIX=.tar +TAR_TIMEOUT=120 TAR_OPTS=( -c ) ORIGINAL_BASEPATH='' EOF @@ -1161,11 +1167,13 @@ HEIGHT=456 THUMBHEIGHT=45 MAXPREVIEWS=9 IMAGE_JOBS=2 +IMAGEMAGICK_TIMEOUT=60 RANDOM_SEED=cli-seed SHUFFLE=yes SPLASH_PAGE=no TARBALL_INCLUDE=yes TARBALL_SUFFIX=.tar +TAR_TIMEOUT=120 TAR_OPTS=( -c ) ORIGINAL_BASEPATH='' EOF @@ -2205,6 +2213,80 @@ test_generate_imagemagick_failure_preserves_dist() { test::teardown } +test_generate_imagemagick_timeout_preserves_dist() { + local config_file + local fake_bin + local output + + test::setup + fake_bin="$TEST_TMPDIR/bin" + config_file="$TEST_TMPDIR/photoalbum.conf" + + test::install_hanging_imagemagick "$fake_bin" + mkdir -p "$TEST_TMPDIR/incoming" "$TEST_TMPDIR/dist" + printf 'fake image\n' > "$TEST_TMPDIR/incoming/01.jpg" + printf 'old dist\n' > "$TEST_TMPDIR/dist/index.html" + printf 'keep me\n' > "$TEST_TMPDIR/dist/sentinel" + test::write_album_config \ + "$config_file" "$TEST_TMPDIR/incoming" "$TEST_TMPDIR/dist" \ + 'Hanging ImageMagick album' 40 + printf 'IMAGEMAGICK_TIMEOUT=1\n' >> "$config_file" + + output=$( + cd "$TEST_TMPDIR" + PATH="$fake_bin:$PATH" TEST_HANG_SECONDS=2 \ + test::capture_failure_output "$TEST_PHOTOALBUM" --generate + ) + + test::assert_contains \ + 'ERROR: ImageMagick timed out after 1 seconds' "$output" + test "$(<"$TEST_TMPDIR/dist/index.html")" = 'old dist' + test "$(<"$TEST_TMPDIR/dist/sentinel")" = 'keep me' + test::assert_path_absent "$TEST_TMPDIR/dist/photos/01.jpg" + test::assert_path_absent "$TEST_TMPDIR/dist/photoalbum.json" + test::assert_no_staging_dirs "$TEST_TMPDIR" + test::teardown +} + +test_generate_tar_timeout_preserves_dist() { + local config_file + local fake_bin + local output + + test::setup + fake_bin="$TEST_TMPDIR/bin" + config_file="$TEST_TMPDIR/photoalbum.conf" + + test::install_fake_imagemagick "$fake_bin" + PATH="$fake_bin:$PATH" \ + test::generate_fixture_images "$TEST_TMPDIR/incoming" + test::install_hanging_tar "$fake_bin" + mkdir -p "$TEST_TMPDIR/dist" + printf 'old dist\n' > "$TEST_TMPDIR/dist/index.html" + printf 'keep me\n' > "$TEST_TMPDIR/dist/sentinel" + test::write_album_config \ + "$config_file" "$TEST_TMPDIR/incoming" "$TEST_TMPDIR/dist" \ + 'Hanging tar album' 40 + { + printf 'TARBALL_INCLUDE=yes\n' + printf 'TAR_TIMEOUT=1\n' + } >> "$config_file" + + output=$( + cd "$TEST_TMPDIR" + PATH="$fake_bin:$PATH" TEST_HANG_SECONDS=2 \ + test::capture_failure_output "$TEST_PHOTOALBUM" --generate + ) + + test::assert_contains 'ERROR: tar timed out after 1 seconds' "$output" + test "$(<"$TEST_TMPDIR/dist/index.html")" = 'old dist' + test "$(<"$TEST_TMPDIR/dist/sentinel")" = 'keep me' + test::assert_path_absent "$TEST_TMPDIR/dist/photoalbum.json" + test::assert_find_count 0 "$TEST_TMPDIR/dist" '*.tar' + test::assert_no_staging_dirs "$TEST_TMPDIR" + test::teardown +} + test_generate_template_failure_preserves_dist() { local config_file local fake_bin @@ -2867,6 +2949,12 @@ main() { '--generate ImageMagick failure preserves final dist' \ test_generate_imagemagick_failure_preserves_dist test::run_case \ + '--generate ImageMagick timeout preserves final dist' \ + test_generate_imagemagick_timeout_preserves_dist + test::run_case \ + '--generate tar timeout preserves final dist' \ + test_generate_tar_timeout_preserves_dist + test::run_case \ '--generate template failure preserves final dist' \ test_generate_template_failure_preserves_dist test::run_case \ diff --git a/tests/helpers.sh b/tests/helpers.sh index ca313fd..bf6000e 100755 --- a/tests/helpers.sh +++ b/tests/helpers.sh @@ -288,6 +288,21 @@ MAGICK cp "$bin_dir/magick" "$bin_dir/convert" } +test::install_hanging_imagemagick() { + local -r bin_dir="$1"; shift + + mkdir -p "$bin_dir" + + cat > "$bin_dir/magick" <<'MAGICK' +#!/usr/bin/env bash +set -euo pipefail + +sleep "${TEST_HANG_SECONDS:-5}" +MAGICK + chmod 0755 "$bin_dir/magick" + cp "$bin_dir/magick" "$bin_dir/convert" +} + test::install_failing_generation_tools() { local -r bin_dir="$1"; shift local name @@ -392,6 +407,20 @@ TAR chmod 0755 "$bin_dir/tar" } +test::install_hanging_tar() { + local -r bin_dir="$1"; shift + + mkdir -p "$bin_dir" + + cat > "$bin_dir/tar" <<'TAR' +#!/usr/bin/env bash +set -euo pipefail + +sleep "${TEST_HANG_SECONDS:-5}" +TAR + chmod 0755 "$bin_dir/tar" +} + test::install_coreutils_without_imagemagick() { local -r bin_dir="$1"; shift local command_path |
