summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-14 16:07:49 +0300
committerPaul Buetow <paul@buetow.org>2026-06-14 16:07:49 +0300
commit57d05cb23238c93d450a871d4115abf7de9b7645 (patch)
tree9d2637043440fb07b18168ba184fe6495d2e08fb
parentfe4e83cd5159809a70f24301fcabc08779b51706 (diff)
6m0 re-raise interrupt to $BASHPID, not $$, in render subshell
The signal handler in source_template_file re-raised the caught signal with `kill -s $sig $$`. Because the function commonly runs inside backgrounded render subshells (queue_album_view_render_job), $$ expands to the main shuriken PID, not the subshell's own. On interrupt the handler therefore signalled the main shell -- which had already cleared its own staging traps -- killing it mid-cleanup (leaking the staging dir) instead of terminating just the render subshell. Re-raise to $BASHPID, the current (sub)shell's real PID, which equals $$ in the foreground case so it is correct everywhere. Extend test_template_interrupt_removes_context_file to run the render in a backgrounded subshell and assert the parent shell survives the re-raise; verified it fails against a $$-based build and passes with $BASHPID. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
-rwxr-xr-xbin/shuriken10
-rw-r--r--src/lib/template.source.sh10
-rwxr-xr-xtests/cli.sh47
3 files changed, 55 insertions, 12 deletions
diff --git a/bin/shuriken b/bin/shuriken
index f911958..06514b1 100755
--- a/bin/shuriken
+++ b/bin/shuriken
@@ -794,10 +794,16 @@ source_template_file() {
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.
+ # signal); $context_file and $BASHPID are escaped to expand when the trap
+ # runs. We re-raise to $BASHPID, not $$: source_template_file commonly
+ # runs in backgrounded render subshells where $$ is the main shuriken
+ # PID, so kill -s $sig $$ would terminate the main shell (mid-cleanup,
+ # after its own staging traps were cleared) instead of this subshell.
+ # $BASHPID is the current (sub)shell's real PID and equals $$ in the
+ # foreground case, so it is correct everywhere.
# shellcheck disable=SC2064
trap "rm -f \"\$context_file\"; trap - INT TERM HUP RETURN; \
- kill -s $sig \$\$" "$sig"
+ kill -s $sig \"\$BASHPID\"" "$sig"
done
if {
diff --git a/src/lib/template.source.sh b/src/lib/template.source.sh
index 152a8be..c23b870 100644
--- a/src/lib/template.source.sh
+++ b/src/lib/template.source.sh
@@ -332,10 +332,16 @@ source_template_file() {
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.
+ # signal); $context_file and $BASHPID are escaped to expand when the trap
+ # runs. We re-raise to $BASHPID, not $$: source_template_file commonly
+ # runs in backgrounded render subshells where $$ is the main shuriken
+ # PID, so kill -s $sig $$ would terminate the main shell (mid-cleanup,
+ # after its own staging traps were cleared) instead of this subshell.
+ # $BASHPID is the current (sub)shell's real PID and equals $$ in the
+ # foreground case, so it is correct everywhere.
# shellcheck disable=SC2064
trap "rm -f \"\$context_file\"; trap - INT TERM HUP RETURN; \
- kill -s $sig \$\$" "$sig"
+ kill -s $sig \"\$BASHPID\"" "$sig"
done
if {
diff --git a/tests/cli.sh b/tests/cli.sh
index 9bd399b..231f4ef 100755
--- a/tests/cli.sh
+++ b/tests/cli.sh
@@ -4095,7 +4095,10 @@ test_template_interrupt_removes_context_file() {
local fake_bin
local started_file
local template_dir
+ local render_pid_file
+ local parent_survived_file
local child_pid
+ local render_pid
local -i status=0
test::setup
@@ -4103,6 +4106,8 @@ test_template_interrupt_removes_context_file() {
template_dir="$TEST_TMPDIR/templates"
context_file="$TEST_TMPDIR/template-context"
started_file="$TEST_TMPDIR/template-started"
+ render_pid_file="$TEST_TMPDIR/render-pid"
+ parent_survived_file="$TEST_TMPDIR/parent-survived"
mkdir -p "$fake_bin" "$template_dir" "$TEST_TMPDIR/dist"
{
printf '#!/usr/bin/env bash\n'
@@ -4119,21 +4124,30 @@ test_template_interrupt_removes_context_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.
+ # Run the render in a BACKGROUNDED subshell of a parent shell, mirroring
+ # production where source_template_file executes inside backgrounded render
+ # jobs (queue_album_view_render_job). We signal the render subshell directly;
+ # the parent then waits on it and records that it survived. A correct signal
+ # handler re-raises to $BASHPID (the subshell), so the parent lives; a buggy
+ # handler that re-raised to $$ would kill this parent shell instead, leaving
+ # the survival marker unwritten. Going through the template entry point
+ # populates a valid render context so the render reaches the blocking sleep.
bash -euo pipefail -s \
"$TEST_SHURIKEN" \
"$fake_bin" \
"$template_dir" \
"$TEST_TMPDIR/dist" \
"$context_file" \
+ "$render_pid_file" \
+ "$parent_survived_file" \
<<'BASH' &
shuriken="$1"; shift
fake_bin="$1"; shift
template_dir="$1"; shift
dist_dir="$1"; shift
context_file="$1"; shift
+render_pid_file="$1"; shift
+parent_survived_file="$1"; shift
# shellcheck source=/dev/null
source <(sed '$d' "$shuriken")
@@ -4152,6 +4166,8 @@ TARBALL_INCLUDE=no
SHURIKEN_OUTPUT_MODE=quiet
apply_config_defaults
+# Background the render so source_template_file runs in its own subshell with a
+# distinct $BASHPID, then expose that subshell PID so the harness can signal it.
template preview out.html \
animation_class '' \
backhref '#' \
@@ -4159,25 +4175,40 @@ template preview out.html \
page_num 1 \
photo photo.jpg \
preview_num 1 \
- thumbs_dir thumbs
+ thumbs_dir thumbs &
+render_pid=$!
+printf '%s' "$render_pid" > "$render_pid_file"
+
+set +e
+wait "$render_pid"
+set -e
+# Reaching here proves the render subshell's re-raised signal did not propagate
+# to this parent shell.
+printf survived > "$parent_survived_file"
BASH
child_pid=$!
- # Wait for the render to begin, then interrupt it with SIGTERM.
+ # Wait for the render to begin, then interrupt the render subshell directly.
while [ ! -f "$started_file" ] && kill -0 "$child_pid" 2>/dev/null; do
sleep 0.05
done
- kill -TERM "$child_pid" 2>/dev/null || true
+ render_pid=$(cat "$render_pid_file")
+ kill -TERM "$render_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
+ # The parent shell must exit cleanly: the render subshell re-raised SIGTERM
+ # to itself ($BASHPID), not to the parent ($$).
+ if (( status != 0 )); then
+ printf 'FAIL: parent shell did not survive render interrupt (status %d)\n' \
+ "$status" >&2
exit 1
fi
+ test::assert_file_exists "$parent_survived_file"
test::assert_path_absent "$context_file"
test::teardown
}