summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-14 15:43:12 +0300
committerPaul Buetow <paul@buetow.org>2026-06-14 15:43:12 +0300
commitfe4e83cd5159809a70f24301fcabc08779b51706 (patch)
tree29b71066a22db0630ed37ced8e8710e8175b83f7
parent3eeef1e0f68d750e7817748180a2007095e99ef3 (diff)
6m0 clean up BASH_ENV context tempfile via traps on interrupt
source_template_file created its context tempfile with mktemp but only ran rm -f on the success and explicit-failure paths. When a render was interrupted by a signal (terminate_active_generation SIGTERMs the backgrounded render subtree on Ctrl-C/abort) the tempfile leaked. Register cleanup in exactly one place, inline in source_template_file's body: - A RETURN trap covers normal and error returns and clears all of these traps (including itself) so it cannot linger and fire on an enclosing function's return against the out-of-scope context_file local (set -u). The trap must be set in the function body, not a helper: without functrace a RETURN trap is not function-scoped and would fire when a helper returns, deleting the file before the render runs. - INT/TERM/HUP handlers remove the file, clear the traps and re-raise so the process still exits with the signal's default disposition. PIPE is not trapped (the internal context-build pipeline emits legitimate SIGPIPE); SIGKILL is untrappable, leaving only the OS-reaped KILL-escalation case. The redundant rm -f calls are removed. Adds test_template_interrupt_removes_context_file, which runs an in-flight render and SIGTERMs it, asserting the context file is gone. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
-rwxr-xr-xbin/shuriken36
-rw-r--r--src/lib/template.source.sh36
-rwxr-xr-xtests/cli.sh95
3 files changed, 163 insertions, 4 deletions
diff --git a/bin/shuriken b/bin/shuriken
index 795e2cb..f911958 100755
--- a/bin/shuriken
+++ b/bin/shuriken
@@ -758,6 +758,7 @@ source_template_file() {
local -r output_path="$1"; shift
local -r render_vars_name="$1"; shift
local context_file
+ local sig
local -i status=0
context_file=$(mktemp)
@@ -766,6 +767,39 @@ source_template_file() {
return "$status"
fi
+ # Single cleanup point for the BASH_ENV context tempfile. The traps MUST be
+ # registered here in source_template_file's own body (not in a helper): a
+ # RETURN trap is not function-scoped unless functrace is enabled, so a trap
+ # set inside a helper would fire when that helper returns and delete the file
+ # before the render even runs.
+ #
+ # The RETURN trap covers normal and error returns (errexit unwinds through
+ # it) and clears ALL of these traps, including itself, so a lingering RETURN
+ # trap cannot fire again on an enclosing function's return against the now
+ # out-of-scope context_file local (which would trip set -u).
+ #
+ # A RETURN trap alone does NOT fire when a signal terminates the shell with
+ # its default disposition. source_template_file also runs in backgrounded
+ # render subshells (see queue_album_view_render_job) that get SIGTERM'd by
+ # terminate_active_generation on interrupt, so we additionally trap the
+ # terminating signals: each handler removes the file, clears the traps and
+ # re-raises the original signal so the process still exits with that signal's
+ # default disposition.
+ #
+ # We deliberately do NOT trap PIPE: the internal "{ ... } | bash" pipeline
+ # below legitimately produces SIGPIPE when the reader closes early, and
+ # trapping it would tear the render down mid-flight. SIGKILL cannot be
+ # trapped either, so a KILL escalation may still leak a file that the OS tmp
+ # reaper later clears; that is the only unavoidable residual case.
+ trap 'rm -f "$context_file"; trap - INT TERM HUP RETURN' RETURN
+ for sig in INT TERM HUP; do
+ # $sig is intentionally expanded now (so each handler re-raises its own
+ # signal); $context_file and $$ are escaped to expand when the trap runs.
+ # shellcheck disable=SC2064
+ trap "rm -f \"\$context_file\"; trap - INT TERM HUP RETURN; \
+ kill -s $sig \$\$" "$sig"
+ done
+
if {
declare -p TEMPLATE_RENDER_FIELD_SPECS
declare -p "$render_vars_name"
@@ -778,7 +812,6 @@ source_template_file() {
status=$?
fi
if (( status != 0 )); then
- rm -f "$context_file"
return "$status"
fi
@@ -788,7 +821,6 @@ source_template_file() {
else
status=$?
fi
- rm -f "$context_file"
if (( status != 0 )); then
return "$status"
fi
diff --git a/src/lib/template.source.sh b/src/lib/template.source.sh
index a42e602..152a8be 100644
--- a/src/lib/template.source.sh
+++ b/src/lib/template.source.sh
@@ -296,6 +296,7 @@ source_template_file() {
local -r output_path="$1"; shift
local -r render_vars_name="$1"; shift
local context_file
+ local sig
local -i status=0
context_file=$(mktemp)
@@ -304,6 +305,39 @@ source_template_file() {
return "$status"
fi
+ # Single cleanup point for the BASH_ENV context tempfile. The traps MUST be
+ # registered here in source_template_file's own body (not in a helper): a
+ # RETURN trap is not function-scoped unless functrace is enabled, so a trap
+ # set inside a helper would fire when that helper returns and delete the file
+ # before the render even runs.
+ #
+ # The RETURN trap covers normal and error returns (errexit unwinds through
+ # it) and clears ALL of these traps, including itself, so a lingering RETURN
+ # trap cannot fire again on an enclosing function's return against the now
+ # out-of-scope context_file local (which would trip set -u).
+ #
+ # A RETURN trap alone does NOT fire when a signal terminates the shell with
+ # its default disposition. source_template_file also runs in backgrounded
+ # render subshells (see queue_album_view_render_job) that get SIGTERM'd by
+ # terminate_active_generation on interrupt, so we additionally trap the
+ # terminating signals: each handler removes the file, clears the traps and
+ # re-raises the original signal so the process still exits with that signal's
+ # default disposition.
+ #
+ # We deliberately do NOT trap PIPE: the internal "{ ... } | bash" pipeline
+ # below legitimately produces SIGPIPE when the reader closes early, and
+ # trapping it would tear the render down mid-flight. SIGKILL cannot be
+ # trapped either, so a KILL escalation may still leak a file that the OS tmp
+ # reaper later clears; that is the only unavoidable residual case.
+ trap 'rm -f "$context_file"; trap - INT TERM HUP RETURN' RETURN
+ for sig in INT TERM HUP; do
+ # $sig is intentionally expanded now (so each handler re-raises its own
+ # signal); $context_file and $$ are escaped to expand when the trap runs.
+ # shellcheck disable=SC2064
+ trap "rm -f \"\$context_file\"; trap - INT TERM HUP RETURN; \
+ kill -s $sig \$\$" "$sig"
+ done
+
if {
declare -p TEMPLATE_RENDER_FIELD_SPECS
declare -p "$render_vars_name"
@@ -316,7 +350,6 @@ source_template_file() {
status=$?
fi
if (( status != 0 )); then
- rm -f "$context_file"
return "$status"
fi
@@ -326,7 +359,6 @@ source_template_file() {
else
status=$?
fi
- rm -f "$context_file"
if (( status != 0 )); then
return "$status"
fi
diff --git a/tests/cli.sh b/tests/cli.sh
index b8b56f6..9bd399b 100755
--- a/tests/cli.sh
+++ b/tests/cli.sh
@@ -4090,6 +4090,98 @@ BASH
test::teardown
}
+test_template_interrupt_removes_context_file() {
+ local context_file
+ local fake_bin
+ local started_file
+ local template_dir
+ local child_pid
+ local -i status=0
+
+ test::setup
+ fake_bin="$TEST_TMPDIR/bin"
+ template_dir="$TEST_TMPDIR/templates"
+ context_file="$TEST_TMPDIR/template-context"
+ started_file="$TEST_TMPDIR/template-started"
+ mkdir -p "$fake_bin" "$template_dir" "$TEST_TMPDIR/dist"
+ {
+ printf '#!/usr/bin/env bash\n'
+ # shellcheck disable=SC2016
+ printf 'printf %%s\\\\n \"$SHURIKEN_FAKE_CONTEXT_FILE\"\n'
+ } > "$fake_bin/mktemp"
+ chmod 0755 "$fake_bin/mktemp"
+ # The rendered template signals it started, then blocks so the render is
+ # genuinely in flight (context already built, env -i bash running) when we
+ # deliver the interrupt. This exercises the signal traps, not the RETURN
+ # trap: a plain RETURN trap does not fire when a signal kills the shell.
+ {
+ printf 'printf rendered > %q\n' "$started_file"
+ printf 'sleep 30\n'
+ } > "$template_dir/preview.tmpl"
+
+ # Run a full template render in a child shell we can signal mid-render. Going
+ # through the template entry point populates a valid render context so the
+ # context build succeeds and the render actually reaches the blocking sleep.
+ bash -euo pipefail -s \
+ "$TEST_SHURIKEN" \
+ "$fake_bin" \
+ "$template_dir" \
+ "$TEST_TMPDIR/dist" \
+ "$context_file" \
+ <<'BASH' &
+shuriken="$1"; shift
+fake_bin="$1"; shift
+template_dir="$1"; shift
+dist_dir="$1"; shift
+context_file="$1"; shift
+
+# shellcheck source=/dev/null
+source <(sed '$d' "$shuriken")
+
+PATH="$fake_bin:$PATH"
+SHURIKEN_FAKE_CONTEXT_FILE="$context_file"
+export SHURIKEN_FAKE_CONTEXT_FILE
+DIST_DIR="$dist_dir"
+TEMPLATE_DIR="$template_dir"
+TITLE='Template interrupt cleanup'
+HEIGHT=''
+THUMBHEIGHT=30
+MAXPREVIEWS=40
+ORIGINAL_BASEPATH=''
+TARBALL_INCLUDE=no
+SHURIKEN_OUTPUT_MODE=quiet
+apply_config_defaults
+
+template preview out.html \
+ animation_class '' \
+ backhref '#' \
+ html_dir . \
+ page_num 1 \
+ photo photo.jpg \
+ preview_num 1 \
+ thumbs_dir thumbs
+BASH
+ child_pid=$!
+
+ # Wait for the render to begin, then interrupt it with SIGTERM.
+ while [ ! -f "$started_file" ] && kill -0 "$child_pid" 2>/dev/null; do
+ sleep 0.05
+ done
+ kill -TERM "$child_pid" 2>/dev/null || true
+ set +e
+ wait "$child_pid"
+ status=$?
+ set -e
+
+ if (( status == 0 )); then
+ printf 'FAIL: expected interrupted render to exit non-zero\n' >&2
+ exit 1
+ fi
+
+ test::assert_path_absent "$context_file"
+ test::teardown
+}
+
test_generate_swap_failure_restores_dist() {
local config_file
local fake_bin
@@ -5034,6 +5126,9 @@ main() {
'template setup failure fails status-tested render' \
test_template_setup_failure_fails_status_tested_render
test::run_case \
+ 'template interrupt removes context file' \
+ test_template_interrupt_removes_context_file
+ test::run_case \
'--generate swap failure restores final dist' \
test_generate_swap_failure_restores_dist
test::run_case \