From a3bba5822903d9dd2d4bb06b3b1948ad235ef498 Mon Sep 17 00:00:00 2001 From: Alexandre <44178713+alexbelgium@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:15:44 +0200 Subject: [PATCH] fix(templates): install the stop handler before startup, not after it (#3054) * fix(templates): install the stop handler before startup, not after it `.templates/ha_entrypoint.sh` installed its SIGTERM/SIGINT trap at the very end of startup, after `wait_for_supervisor` and after the whole `/etc/cont-init.d/*` loop. 105 of the 136 add-ons here set `init: false`, which makes this script namespace PID 1, and the kernel discards a signal whose handler is still SIG_DFL. A stop arriving during startup was therefore lost outright: Home Assistant waited out the grace period, the container died on SIGKILL, and the add-on surfaced as Error. The trap now goes in immediately after the PID1 detection block, still behind an `if $PID1` guard so the 31 add-ons running under Docker's own init keep their current signal behaviour. `terminate()` itself is unchanged apart from indentation. Measured on this file, run as PID 1 in a PID+mount namespace with a bind-mounted `/etc/cont-init.d`: SIGTERM 3s into an 8s cont-init script before: never exits (alive >40s) after: exits 5s later, script wrote 8/8 SIGTERM before cont-init starts before: never exits (alive >40s) after: exits 8s later, script wrote 8/8 SIGTERM during 8 fast cont-init scripts before: never exits (alive >20s) after: exits in 0s, cleanly after 6 of 8 Nothing is interrupted mid-write. Only PID 1 is signalled and bash defers a trap to the next command boundary, so the in-flight command always reaps first: a `dd` writing 16,384,000 bytes over ~5s took the entrypoint's SIGTERM at 2s and still wrote all 16,384,000 bytes. The residual is that a stop during one long-running cont-init script waits for that script. Backgrounding the loop and killing the child would remove it, but that truncates the child mid-write, and `01-config_yaml.sh` and `19-json_repair.sh` both write non-atomically into the persisted `/config`. Waiting was preferred over risking a user's config. omni-tools is bumped so this gets a CI build and the fix reaches the add-on the report came from; every other add-on picks it up on its next rebuild. Co-Authored-By: Claude Opus 5 * fix(templates): make terminate() errexit-safe before it can run early Moving the trap to the top of the script exposed a latent hazard that only existed once the handler could fire before the application was up. `validate_shebang()` leaves `set -e` on in the outer shell, so `terminate()` runs under errexit -- measured: the shell flags at the cont-init stage are `ehB`. Under errexit the first failing command in a trap handler aborts the handler and exits with that status, skipping the child-kill loop and the `exit 0`. Every command in the stock body is already guarded with `|| true`, `|| echo` or an `if`, so this was inert. But postgres_15 and postgres_17 patch this function at build time (both `Dockerfile:77`): sed -i "/Termination signal received/a gosu postgres pg_ctl -D \"$PGDATA\" -m fast stop" That command is unguarded, and it fails whenever the stop arrives before the database is up. With the trap installed late, that never happened: postgres was already running by the time the handler existed. With the trap installed early the window is real -- it spans `wait_for_supervisor` (up to 30s) and the whole cont-init chain, which for postgres_15 includes a `chown -R` over `$PGDATA`. Replaying that exact sed against the patched entrypoint and stopping during cont-init: without set +e exit 127, handler aborted before killing any child with set +e exit 0, handler ran to completion Exiting non-zero on a stop is what Home Assistant reports as Error, so without this the change would have reproduced the reported symptom for those two add-ons in a narrower window. `set +e` states the intent the guarded body already implies, and covers the injected line without postgres_15/17 needing an edit. They are the only two add-ons that patch terminate(); the other six Dockerfiles that sed the entrypoint touch other regions. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: BirdNET-Go Addon Builder Co-authored-by: Claude Opus 5 --- .templates/ha_entrypoint.sh | 73 ++++++++++++++++++++++++------------- omni-tools/CHANGELOG.md | 3 ++ omni-tools/config.yaml | 2 +- 3 files changed, 52 insertions(+), 26 deletions(-) diff --git a/.templates/ha_entrypoint.sh b/.templates/ha_entrypoint.sh index b8e1fd34e5..0f02dd1ade 100755 --- a/.templates/ha_entrypoint.sh +++ b/.templates/ha_entrypoint.sh @@ -16,6 +16,54 @@ else echo "Starting custom scripts" fi +########################################## +# Install the stop handler # +########################################## + +# As namespace PID 1 -- which is what "init: false" makes this script -- the kernel +# discards any signal whose handler is still SIG_DFL. Installed at the end of startup, +# as it used to be, the handler missed every stop that arrived while the Supervisor +# probe or the cont-init chain was still running: Home Assistant waited out the grace +# period for a SIGKILL and reported the add-on as failed. Nothing here is interrupted +# mid-write by the move -- only PID 1 is signalled, and bash runs a trap at a command +# boundary, so whatever external command is in flight reaps first. +terminate() { + local local_pid + # Best-effort, so errexit must not apply: this runs with `set -e` in force (validate_shebang + # leaves it on), and under errexit the first failing command aborts the handler and exits with + # its status, skipping the child-kill loop and the `exit 0` below. Every command in the body + # here is already guarded, but add-ons patch this function at build time -- postgres_15 and + # postgres_17 sed an unguarded `pg_ctl ... stop` in right after the echo -- and that command + # fails whenever the stop arrives before the database is up. + set +e + echo "Termination signal received, forwarding to subprocesses..." + if command -v pgrep >/dev/null 2>&1; then + while read -r pid; do + [ -n "$pid" ] || continue + echo "Terminating child PID $pid" + kill -TERM "$pid" 2>/dev/null || echo "Failed to terminate PID $pid" + done < <(pgrep -P "$$" || true) + else + for p in /proc/[0-9]*/; do + local_pid="${p#/proc/}" + local_pid="${local_pid%/}" + if [ "$local_pid" -ne 1 ] && grep -q "^PPid:[[:space:]]*$$" "/proc/$local_pid/status" 2>/dev/null; then + echo "Terminating child PID $local_pid" + kill -TERM "$local_pid" 2>/dev/null || echo "Failed to terminate PID $local_pid" + fi + done + fi + wait || true + echo "All subprocesses terminated. Exiting." + exit 0 +} + +# Only when this script is PID 1. Under Docker's own init the entrypoint is an ordinary +# child and the signal handling above belongs to that init, not to us. +if $PID1; then + trap terminate SIGTERM SIGINT +fi + ########################################## # Pick an exec-capable directory # ########################################## @@ -428,31 +476,6 @@ if $PID1; then echo " " echo -e "\033[0;32mEverything started!\033[0m" - terminate() { - local local_pid - echo "Termination signal received, forwarding to subprocesses..." - if command -v pgrep >/dev/null 2>&1; then - while read -r pid; do - [ -n "$pid" ] || continue - echo "Terminating child PID $pid" - kill -TERM "$pid" 2>/dev/null || echo "Failed to terminate PID $pid" - done < <(pgrep -P "$$" || true) - else - for p in /proc/[0-9]*/; do - local_pid="${p#/proc/}" - local_pid="${local_pid%/}" - if [ "$local_pid" -ne 1 ] && grep -q "^PPid:[[:space:]]*$$" "/proc/$local_pid/status" 2>/dev/null; then - echo "Terminating child PID $local_pid" - kill -TERM "$local_pid" 2>/dev/null || echo "Failed to terminate PID $local_pid" - fi - done - fi - wait || true - echo "All subprocesses terminated. Exiting." - exit 0 - } - - trap terminate SIGTERM SIGINT while :; do sleep infinity & wait $! diff --git a/omni-tools/CHANGELOG.md b/omni-tools/CHANGELOG.md index 84876f01dc..514f02cd07 100644 --- a/omni-tools/CHANGELOG.md +++ b/omni-tools/CHANGELOG.md @@ -1,3 +1,6 @@ +## 0.6.1.2 (2026-09-07) +- Rebuild to pick up a shared `ha_entrypoint.sh` fix: the SIGTERM/SIGINT handler is now installed at the top of the entrypoint instead of at the end of startup. With `init: false` the entrypoint is namespace PID 1, and the kernel discards a signal that PID 1 has no handler for, so a stop arriving while the Supervisor probe or the `cont-init.d` chain was still running was lost entirely and the add-on died only when the grace period expired into SIGKILL. Stops are now honoured from the first moment of startup. No change to add-on behaviour otherwise. + ## 0.6.1.1 (2026-09-07) - Fix the add-on refusing to stop: Home Assistant reported an Error status after a few seconds and the container kept running and serving the web UI. `cont-init.d/99-run.sh` started nginx in the foreground, and `ha_entrypoint.sh` runs every cont-init script in the foreground, so the entrypoint never reached the point where it installs the `terminate()` handler that forwards SIGTERM to the application on shutdown. The application is now started in the background, and `init: false` makes the entrypoint run as PID 1 so the orphaned process is reparented to it and receives that signal. Closes #3049. diff --git a/omni-tools/config.yaml b/omni-tools/config.yaml index c837256c08..1a9ca5f8e8 100644 --- a/omni-tools/config.yaml +++ b/omni-tools/config.yaml @@ -21,5 +21,5 @@ schema: value: str? slug: omni-tools url: https://github.com/alexbelgium/hassio-addons -version: 0.6.1.1 +version: 0.6.1.2 webui: "[PROTO:ssl]://[HOST]:[PORT:80]"