Files
Stirling-PDF/scripts/init-without-ocr.sh
Balázs Szücs 9ac260ee92 feat(aot): add aot-diagnostics.sh for AOT cache diagnostics and validation (#5848)
# Description of Changes



This pull request makes significant improvements to the Docker build
process for the embedded Stirling-PDF image, focusing on build
efficiency, runtime optimization, and maintainability. Key changes
include upgrading major tool versions, introducing optional stripping of
Calibre's WebEngine to reduce image size, consolidating ImageMagick
layers, and refining the Python environment build process. The runtime
image is now leaner, with clearer separation between build and runtime
dependencies, and improved caching for faster builds and pulls.

**Build and Dependency Management Improvements**
* Upgraded Calibre to version `9.4.0` and added support for the
`TARGETPLATFORM` build argument for multi-platform builds.
* Added an optional `CALIBRE_STRIP_WEBENGINE` build argument to strip
Chromium/WebEngine from Calibre, saving ~80 MB when PDF output via
Calibre is not needed.
* Consolidated ImageMagick outputs into a single staging directory
(`/magick-export`) to reduce Docker layers and improve caching
efficiency.
* Refactored Python virtual environment build: now built in a dedicated
stage with pre-built wheels and copied into the runtime image,
eliminating the need for build tools and pip installs at runtime.

**Runtime Image Optimization**
* Reduced installed system packages to only what is needed at runtime;
Python build tools and dev packages are no longer included.
* Cleaned up unnecessary runtime files, including removal of build-only
Python artifacts and system headers, for a smaller and more secure
image.

**Layer and Copy Optimization**
* Switched to `COPY --link` for all major external tool layers and
application files, enabling independent layer caching and parallel pulls
for faster builds.

**Runtime Configuration and Health**
* Improved runtime directory structure and permissions, added persistent
cache directories for Project Leyden AOT, and wrote the version tag to
`/etc/stirling_version` for easier script access.
* Updated the healthcheck to wait longer for startup and increased
timeout/retries for more robust readiness detection.

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
2026-03-03 19:06:46 +00:00

1032 lines
36 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/bin/bash
# This script initializes Stirling PDF without OCR features.
set -euo pipefail
log() {
if [ $# -eq 0 ]; then
cat >&2
else
printf '%s\n' "$*" >&2
fi
}
command_exists() { command -v "$1" >/dev/null 2>&1; }
if [ -d /scripts ] && [[ ":${PATH}:" != *":/scripts:"* ]]; then
export PATH="/scripts:${PATH}"
fi
if [ -x /scripts/stirling-diagnostics.sh ]; then
mkdir -p /usr/local/bin
ln -sf /scripts/stirling-diagnostics.sh /usr/local/bin/diagnostics
ln -sf /scripts/stirling-diagnostics.sh /usr/local/bin/stirling-diagnostics
ln -sf /scripts/stirling-diagnostics.sh /usr/local/bin/diag
ln -sf /scripts/stirling-diagnostics.sh /usr/local/bin/debug
ln -sf /scripts/stirling-diagnostics.sh /usr/local/bin/diagnostic
fi
if [ -x /scripts/aot-diagnostics.sh ] && [ "${STIRLING_AOT_ENABLE:-false}" = "true" ]; then
mkdir -p /usr/local/bin
ln -sf /scripts/aot-diagnostics.sh /usr/local/bin/aot-diag
ln -sf /scripts/aot-diagnostics.sh /usr/local/bin/aot-diagnostics
fi
print_versions() {
set +o pipefail
log "--- Binary Versions ---"
command_exists java && java -version 2>&1 | head -n 1 | log
command_exists qpdf && qpdf --version | head -n 1 | log
command_exists magick && magick --version | head -n 1 | log
# Use python to get versions of pip-installed tools to be sure
command_exists ocrmypdf && ocrmypdf --version 2>&1 | head -n 1 | printf "ocrmypdf %s\n" "$(cat)" | log
command_exists soffice && soffice --version | head -n 1 | log
command_exists unoserver && unoserver --version 2>&1 | head -n 1 | log
command_exists tesseract && tesseract --version | head -n 1 | log
command_exists gs && gs --version | printf "Ghostscript %s\n" "$(cat)" | log
command_exists ffmpeg && ffmpeg -version | head -n 1 | log
command_exists pdfinfo && pdfinfo -v 2>&1 | head -n 1 | log
command_exists fontforge && fontforge --version 2>&1 | head -n 1 | log
command_exists unpaper && unpaper --version 2>&1 | head -n 1 | log
command_exists ebook-convert && ebook-convert --version 2>&1 | head -n 1 | log
log "-----------------------"
set -o pipefail
}
cleanup() {
# Prevent re-entrance from double signals
trap '' SIGTERM EXIT
log "Shutdown signal received. Cleaning up..."
# Kill background AOT generation first (least important, clean up tmp files)
if [ -n "${AOT_GEN_PID:-}" ] && kill -0 "$AOT_GEN_PID" 2>/dev/null; then
kill -TERM "$AOT_GEN_PID" 2>/dev/null || true
wait "$AOT_GEN_PID" 2>/dev/null || true
fi
# Signal unoserver instances to shut down
for pid in "${UNOSERVER_PIDS[@]:-}"; do
[ -n "$pid" ] && kill -TERM "$pid" 2>/dev/null || true
done
# Signal Java to shut down gracefully, Spring Boot handles SIGTERM cleanly
if [ -n "${JAVA_PID:-}" ] && kill -0 "$JAVA_PID" 2>/dev/null; then
kill -TERM "$JAVA_PID" 2>/dev/null || true
# Wait up to 30s for graceful shutdown before forcing
local _i=0
while [ "$_i" -lt 30 ] && kill -0 "$JAVA_PID" 2>/dev/null; do
sleep 1
_i=$((_i + 1))
done
if kill -0 "$JAVA_PID" 2>/dev/null; then
log "Java did not exit within 30s, sending SIGKILL"
kill -KILL "$JAVA_PID" 2>/dev/null || true
fi
fi
# Kill any remaining children (watchdog, Xvfb, etc.)
pkill -P $$ 2>/dev/null || true
log "Cleanup complete."
}
trap cleanup SIGTERM
trap cleanup EXIT
print_versions
run_with_timeout() {
local secs=$1; shift
if command_exists timeout; then
timeout "${secs}s" "$@"
else
"$@"
fi
}
tcp_port_check() {
local host=$1
local port=$2
local timeout_secs=${3:-5}
# Try nc first (most portable)
if command_exists nc; then
run_with_timeout "$timeout_secs" nc -z "$host" "$port" 2>/dev/null
return $?
fi
# Fallback to /dev/tcp (bash-specific)
if [ -n "${BASH_VERSION:-}" ] && command_exists bash; then
run_with_timeout "$timeout_secs" bash -c "exec 3<>/dev/tcp/${host}/${port}" 2>/dev/null
local result=$?
exec 3>&- 2>/dev/null || true
return $result
fi
# No TCP check method available; caller uses ==2 to fall back to PID-only logic
return 2
}
check_unoserver_port_ready() {
local port=$1
local silent=${2:-}
# Try unoping first (best - checks actual server health)
if [ -n "${UNOPING_BIN:-}" ]; then
if run_as_runtime_user_with_timeout 5 "$UNOPING_BIN" --host 127.0.0.1 --port "$port" >/dev/null 2>&1; then
return 0
fi
if [ "$silent" != "silent" ]; then
log "unoserver health check failed (unoping) for port ${port}, trying TCP fallback"
fi
fi
# Fallback to TCP port check (verifies service is listening)
tcp_port_check "127.0.0.1" "$port" 5
local tcp_rc=$?
if [ $tcp_rc -eq 0 ]; then
return 0
elif [ $tcp_rc -eq 2 ]; then
if [ "$silent" != "silent" ]; then
log "No TCP check available; falling back to PID-only for port ${port}"
fi
return 0
else
if [ "$silent" != "silent" ]; then
log "unoserver TCP check failed for port ${port}"
fi
fi
return 1
}
check_unoserver_ready() {
local silent=${1:-}
if [ "${#UNOSERVER_PORTS[@]}" -eq 0 ]; then
log "Skipping unoserver readiness check (no local ports started)"
return 0
fi
for port in "${UNOSERVER_PORTS[@]}"; do
if ! check_unoserver_port_ready "$port" "$silent"; then
return 1
fi
done
return 0
}
UNOSERVER_PIDS=()
UNOSERVER_PORTS=()
UNOSERVER_UNO_PORTS=()
SU_EXEC_BIN=""
if command_exists su-exec; then
SU_EXEC_BIN="su-exec"
elif command_exists gosu; then
SU_EXEC_BIN="gosu"
fi
CURRENT_USER="$(id -un)"
CURRENT_UID="$(id -u)"
SWITCH_USER_WARNING_EMITTED=false
warn_switch_user_once() {
if [ "$SWITCH_USER_WARNING_EMITTED" = false ]; then
log "WARNING: Unable to switch to user ${RUNTIME_USER:-stirlingpdfuser}; running command as ${CURRENT_USER}."
SWITCH_USER_WARNING_EMITTED=true
fi
}
run_as_runtime_user() {
if [ "$CURRENT_USER" = "$RUNTIME_USER" ]; then
"$@"
elif [ "$CURRENT_UID" -eq 0 ] && [ -n "$SU_EXEC_BIN" ]; then
"$SU_EXEC_BIN" "$RUNTIME_USER" "$@"
else
warn_switch_user_once
"$@"
fi
}
run_as_runtime_user_with_timeout() {
local secs=$1; shift
if command_exists timeout; then
run_as_runtime_user timeout "${secs}s" "$@"
else
run_as_runtime_user "$@"
fi
}
CONFIG_FILE=${CONFIG_FILE:-/configs/settings.yml}
read_setting_value() {
local key=$1
if [ ! -f "$CONFIG_FILE" ]; then
return
fi
awk -F: -v key="$key" '
$1 ~ "^[[:space:]]*"key"[[:space:]]*$" {
val=$2
sub(/#.*/, "", val)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", val)
gsub(/^["'"'"']|["'"'"']$/, "", val)
print val
exit
}
' "$CONFIG_FILE"
}
get_unoserver_auto() {
if [ -n "${PROCESS_EXECUTOR_AUTO_UNO_SERVER:-}" ]; then
echo "$PROCESS_EXECUTOR_AUTO_UNO_SERVER"
return
fi
if [ -n "${UNO_SERVER_AUTO:-}" ]; then
echo "$UNO_SERVER_AUTO"
return
fi
read_setting_value "autoUnoServer"
}
get_unoserver_count() {
if [ -n "${PROCESS_EXECUTOR_SESSION_LIMIT_LIBRE_OFFICE_SESSION_LIMIT:-}" ]; then
echo "$PROCESS_EXECUTOR_SESSION_LIMIT_LIBRE_OFFICE_SESSION_LIMIT"
return
fi
if [ -n "${UNO_SERVER_COUNT:-}" ]; then
echo "$UNO_SERVER_COUNT"
return
fi
read_setting_value "libreOfficeSessionLimit"
}
start_unoserver_instance() {
local port=$1
local uno_port=$2
# Suppress repetitive POST /RPC2 access logs from health checks
run_as_runtime_user "$UNOSERVER_BIN" \
--interface 127.0.0.1 \
--port "$port" \
--uno-port "$uno_port" \
2> >(grep --line-buffered -v "POST /RPC2" >&2) \
&
LAST_UNOSERVER_PID=$!
}
start_unoserver_watchdog() {
local interval=${UNO_SERVER_HEALTH_INTERVAL:-120}
case "$interval" in
''|*[!0-9]*) interval=120 ;;
esac
(
while true; do
local i=0
while [ "$i" -lt "${#UNOSERVER_PIDS[@]}" ]; do
local pid=${UNOSERVER_PIDS[$i]}
local port=${UNOSERVER_PORTS[$i]}
local uno_port=${UNOSERVER_UNO_PORTS[$i]}
local needs_restart=false
# Check PID and Health
if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then
log "unoserver PID ${pid} not found for port ${port}"
needs_restart=true
elif ! check_unoserver_port_ready "$port"; then
needs_restart=true
fi
if [ "$needs_restart" = true ]; then
log "Restarting unoserver on 127.0.0.1:${port} (uno-port ${uno_port})"
# Kill the old process if it exists
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
kill -TERM "$pid" 2>/dev/null || true
sleep 1
kill -KILL "$pid" 2>/dev/null || true
fi
start_unoserver_instance "$port" "$uno_port"
UNOSERVER_PIDS[$i]=$LAST_UNOSERVER_PID
fi
i=$((i + 1))
done
sleep "$interval"
done
) &
}
start_unoserver_pool() {
local auto
auto="$(get_unoserver_auto)"
auto="${auto,,}"
if [ -z "$auto" ]; then
auto="true"
fi
if [ "$auto" != "true" ]; then
log "Skipping local unoserver pool (autoUnoServer=$auto)"
return 0
fi
local count
count="$(get_unoserver_count)"
case "$count" in
''|*[!0-9]*) count=1 ;;
esac
if [ "$count" -le 0 ]; then
count=1
fi
local i=0
while [ "$i" -lt "$count" ]; do
local port=$((2003 + (i * 2)))
local uno_port=$((2004 + (i * 2)))
log "Starting unoserver on 127.0.0.1:${port} (uno-port ${uno_port})"
UNOSERVER_PORTS+=("$port")
UNOSERVER_UNO_PORTS+=("$uno_port")
start_unoserver_instance "$port" "$uno_port"
UNOSERVER_PIDS+=("$LAST_UNOSERVER_PID")
i=$((i + 1))
done
# Small delay to let servers bind
sleep 2
}
# ---------- VERSION_TAG ----------
# Load VERSION_TAG from file if not provided via environment.
if [ -z "${VERSION_TAG:-}" ] && [ -f /etc/stirling_version ]; then
VERSION_TAG="$(tr -d '\r\n' < /etc/stirling_version)"
export VERSION_TAG
fi
# ---------- AOT ----------
# OFF by default. Set STIRLING_AOT_ENABLE=true to opt in.
AOT_ENABLED="${STIRLING_AOT_ENABLE:-false}"
# ---------- Dynamic Memory Detection ----------
# Detects the container memory limit (in MB) from cgroups v2/v1 or /proc/meminfo.
detect_container_memory_mb() {
local mem_bytes=""
# cgroups v2
if [ -f /sys/fs/cgroup/memory.max ]; then
mem_bytes=$(cat /sys/fs/cgroup/memory.max 2>/dev/null)
if [ "$mem_bytes" = "max" ]; then
mem_bytes=""
fi
fi
# cgroups v1 fallback
if [ -z "$mem_bytes" ] && [ -f /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then
mem_bytes=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null)
# Values near max uint64 mean "unlimited"
# Use string-length heuristic (>=19 digits) to avoid shell integer overflow on Alpine/busybox
if [ "${#mem_bytes}" -ge 19 ]; then
mem_bytes=""
fi
fi
# Fallback to system total memory
if [ -z "$mem_bytes" ]; then
mem_bytes=$(awk '/MemTotal/ {print $2 * 1024}' /proc/meminfo 2>/dev/null)
fi
if [ -n "$mem_bytes" ] && [ "$mem_bytes" -gt 0 ] 2>/dev/null; then
echo $(( mem_bytes / 1048576 ))
else
echo "0"
fi
}
# Computes dynamic JVM memory flags based on detected container memory and profile.
# Sets: DYNAMIC_INITIAL_RAM_PCT, DYNAMIC_MAX_RAM_PCT, DYNAMIC_MAX_METASPACE
compute_dynamic_memory() {
local mem_mb=$1
local profile=${2:-balanced}
if [ "$mem_mb" -le 0 ] 2>/dev/null; then
# Cannot detect memory; use safe defaults
DYNAMIC_INITIAL_RAM_PCT=10
DYNAMIC_MAX_RAM_PCT=75
DYNAMIC_MAX_METASPACE=256
return
fi
log "Detected container memory: ${mem_mb}MB"
# NOTE: MaxRAMPercentage governs HEAP only. Total JVM footprint also includes:
# - Metaspace (MaxMetaspaceSize)
# - Code cache (~100-200MB)
# - Thread stacks (~1MB each × virtual threads)
# - Direct byte buffers, native memory
# Rule of thumb: heap% + (metaspace + ~200MB overhead) should fit in container.
if [ "$mem_mb" -le 512 ]; then
DYNAMIC_INITIAL_RAM_PCT=30
DYNAMIC_MAX_RAM_PCT=55
DYNAMIC_MAX_METASPACE=96
elif [ "$mem_mb" -le 1024 ]; then
DYNAMIC_INITIAL_RAM_PCT=25
DYNAMIC_MAX_RAM_PCT=60
DYNAMIC_MAX_METASPACE=128
elif [ "$mem_mb" -le 2048 ]; then
DYNAMIC_INITIAL_RAM_PCT=20
DYNAMIC_MAX_RAM_PCT=65
DYNAMIC_MAX_METASPACE=192
elif [ "$mem_mb" -le 4096 ]; then
DYNAMIC_INITIAL_RAM_PCT=15
DYNAMIC_MAX_RAM_PCT=70
DYNAMIC_MAX_METASPACE=256
else
# Large memory: be conservative to leave room for off-heap (LibreOffice, Calibre, etc.)
if [ "$profile" = "performance" ]; then
DYNAMIC_INITIAL_RAM_PCT=20
DYNAMIC_MAX_RAM_PCT=70
DYNAMIC_MAX_METASPACE=512
else
DYNAMIC_INITIAL_RAM_PCT=10
DYNAMIC_MAX_RAM_PCT=50
DYNAMIC_MAX_METASPACE=256
fi
fi
log "Dynamic memory: InitialRAM=${DYNAMIC_INITIAL_RAM_PCT}%, MaxRAM=${DYNAMIC_MAX_RAM_PCT}%, MaxMeta=${DYNAMIC_MAX_METASPACE}m"
}
# ---------- Project Leyden AOT Cache (JEP 483 + 514 + 515) ----------
# Replaces legacy AppCDS with JDK 25's AOT cache. Uses the three-step workflow:
# 1. RECORD , runs Spring context init, captures class loading + method profiles
# 2. CREATE , builds the AOT cache file (does NOT start the app)
# 3. RUNTIME, java -XX:AOTCache=... starts with pre-linked classes + compiled methods
# Constraints:
# - Cache must be generated on the same JDK build + OS + arch as production (satisfied
# because we generate inside the same container image at runtime)
# - ZGC not supported until JDK 26 (G1GC and Shenandoah are fully supported)
# - Signed JARs (BouncyCastle) are silently skipped, no warnings, no functionality loss
generate_aot_cache() {
local aot_path="$1"
shift
# Remaining args ($@) are the classpath/main-class arguments for the training run
local aot_dir
aot_dir=$(dirname "$aot_path")
mkdir -p "$aot_dir" 2>/dev/null || true
local aot_conf="/tmp/stirling.aotconf"
local arch
arch=$(uname -m)
# ── ARM-aware heap sizing ──
# ARM devices (Raspberry Pi, Ampere) often have tighter memory.
# Scale training heap down to avoid OOM-killing the background generation.
local record_xmx="512m"
local create_xmx="256m"
if [ "${CONTAINER_MEM_MB:-0}" -gt 0 ] && [ "${CONTAINER_MEM_MB}" -le 1024 ]; then
record_xmx="256m"
create_xmx="128m"
fi
# ── ARM-aware timeouts ──
# ARM under QEMU or on slow SD/eMMC can take much longer than x86_64.
local record_timeout=300
local create_timeout=180
if [ "$arch" = "aarch64" ]; then
record_timeout=600
create_timeout=300
fi
log "AOT: arch=${arch} mem=${CONTAINER_MEM_MB:-?}MB heap=${record_xmx} timeouts=${record_timeout}s/${create_timeout}s"
log "AOT: COMPACT_HEADERS='${COMPACT_HEADERS_FLAG:-<none>}' COMPRESSED_OOPS='${COMPRESSED_OOPS_FLAG}'"
log "AOT: Phase 1/2, Recording class loading + method profiles..."
# RECORD, starts Spring context, observes class loading + collects method profiles (JEP 515).
# Non-zero exit is expected: -Dspring.context.exit=onRefresh triggers controlled shutdown.
# Uses in-memory H2 to avoid file-lock conflicts with the running app.
# COMPACT_HEADERS_FLAG/COMPRESSED_OOPS_FLAG must exactly match the runtime invocation.
# Clear all JVM option env vars so external settings (e.g. _JAVA_OPTIONS=-Xms14G) cannot
# conflict with the explicit -Xmx we pass here. Training uses its own minimal flag set.
local record_exit=0
if command_exists timeout; then
JAVA_TOOL_OPTIONS= JDK_JAVA_OPTIONS= _JAVA_OPTIONS= \
timeout "${record_timeout}s" \
java "-Xmx${record_xmx}" ${COMPACT_HEADERS_FLAG:-} ${COMPRESSED_OOPS_FLAG} \
-Xlog:aot=error \
-XX:AOTMode=record \
-XX:AOTConfiguration="$aot_conf" \
-Dspring.main.banner-mode=off \
-Dspring.context.exit=onRefresh \
-Dstirling.datasource.url="jdbc:h2:mem:aottraining;DB_CLOSE_DELAY=-1;MODE=PostgreSQL" \
"$@" >/tmp/aot-record.log 2>&1 || record_exit=$?
else
JAVA_TOOL_OPTIONS= JDK_JAVA_OPTIONS= _JAVA_OPTIONS= \
java "-Xmx${record_xmx}" ${COMPACT_HEADERS_FLAG:-} ${COMPRESSED_OOPS_FLAG} \
-Xlog:aot=error \
-XX:AOTMode=record \
-XX:AOTConfiguration="$aot_conf" \
-Dspring.main.banner-mode=off \
-Dspring.context.exit=onRefresh \
-Dstirling.datasource.url="jdbc:h2:mem:aottraining;DB_CLOSE_DELAY=-1;MODE=PostgreSQL" \
"$@" >/tmp/aot-record.log 2>&1 || record_exit=$?
fi
if [ "$record_exit" -eq 124 ]; then
log "AOT: RECORD phase timed out after ${record_timeout}s, skipping"
rm -f "$aot_conf" /tmp/aot-record.log
return 1
fi
if [ "$record_exit" -eq 137 ]; then
log "AOT: RECORD phase OOM-killed (exit 137), container memory too low for training"
log "AOT: Set STIRLING_AOT_ENABLE=false or increase container memory above 1GB"
rm -f "$aot_conf" /tmp/aot-record.log
return 1
fi
if [ ! -f "$aot_conf" ]; then
log "AOT: Training produced no configuration file (exit=${record_exit}), last 30 lines:"
tail -30 /tmp/aot-record.log 2>/dev/null | while IFS= read -r line; do log " $line"; done
rm -f /tmp/aot-record.log
return 1
fi
log "AOT: Phase 1 complete, conf $(du -h "$aot_conf" 2>/dev/null | cut -f1)"
log "AOT: Phase 2/2, Creating AOT cache from recorded profile..."
# CREATE, does NOT start the application; builds pre-linked class + method data.
local create_exit=0
if command_exists timeout; then
JAVA_TOOL_OPTIONS= JDK_JAVA_OPTIONS= _JAVA_OPTIONS= \
timeout "${create_timeout}s" \
java "-Xmx${create_xmx}" ${COMPACT_HEADERS_FLAG:-} ${COMPRESSED_OOPS_FLAG} \
-Xlog:aot=error \
-XX:AOTMode=create \
-XX:AOTConfiguration="$aot_conf" \
-XX:AOTCache="$aot_path" \
"$@" >/tmp/aot-create.log 2>&1 || create_exit=$?
else
JAVA_TOOL_OPTIONS= JDK_JAVA_OPTIONS= _JAVA_OPTIONS= \
java "-Xmx${create_xmx}" ${COMPACT_HEADERS_FLAG:-} ${COMPRESSED_OOPS_FLAG} \
-Xlog:aot=error \
-XX:AOTMode=create \
-XX:AOTConfiguration="$aot_conf" \
-XX:AOTCache="$aot_path" \
"$@" >/tmp/aot-create.log 2>&1 || create_exit=$?
fi
if [ "$create_exit" -eq 124 ]; then
log "AOT: CREATE phase timed out after ${create_timeout}s"
rm -f "$aot_conf" "$aot_path" /tmp/aot-record.log /tmp/aot-create.log
return 1
fi
if [ "$create_exit" -eq 137 ]; then
log "AOT: CREATE phase OOM-killed (exit 137)"
rm -f "$aot_conf" "$aot_path" /tmp/aot-record.log /tmp/aot-create.log
return 1
fi
if [ "$create_exit" -eq 0 ] && [ -f "$aot_path" ] && [ -s "$aot_path" ]; then
local cache_size
cache_size=$(du -h "$aot_path" 2>/dev/null | cut -f1)
log "AOT: Cache created successfully: $aot_path ($cache_size)"
chmod 644 "$aot_path" 2>/dev/null || true
save_aot_fingerprint "$aot_path"
rm -f "$aot_conf" /tmp/aot-record.log /tmp/aot-create.log
return 0
else
log "AOT: Cache creation failed (exit=${create_exit}), last 30 lines:"
tail -30 /tmp/aot-create.log 2>/dev/null | while IFS= read -r line; do log " $line"; done
rm -f "$aot_conf" "$aot_path" /tmp/aot-record.log /tmp/aot-create.log
return 1
fi
}
# ---------- AOT Cache Fingerprinting ----------
# Detects stale caches automatically when the app JAR, JDK version, arch, or JVM flags change.
# Stores a short hash alongside the cache file; mismatch → cache is deleted and regenerated.
compute_aot_fingerprint() {
local fp=""
# Clear JAVA_TOOL_OPTIONS / JDK_JAVA_OPTIONS so the JVM does not prepend
# "Picked up JAVA_TOOL_OPTIONS: ..." to stderr before the version line.
# Those vars are exported by the time the background subshell runs
# save_aot_fingerprint, but are NOT yet set when validate_aot_cache runs on
# the next boot -- causing head -1 to return different strings each time.
fp+="jdk:$(JAVA_TOOL_OPTIONS= JDK_JAVA_OPTIONS= _JAVA_OPTIONS= java -version 2>&1 | head -1);"
fp+="arch:$(uname -m);"
fp+="compact:${COMPACT_HEADERS_FLAG:-none};"
fp+="oops:${COMPRESSED_OOPS_FLAG:-none};"
# App identity: size+mtime is fast (avoids hashing 200MB JARs)
if [ -f /app/app.jar ]; then
fp+="app:$(stat -c '%s-%Y' /app/app.jar 2>/dev/null || echo unknown);"
elif [ -f /app.jar ]; then
fp+="app:$(stat -c '%s-%Y' /app.jar 2>/dev/null || echo unknown);"
elif [ -d /app/lib ]; then
fp+="app:$(ls -la /app/lib/ 2>/dev/null | md5sum 2>/dev/null | cut -c1-16 || echo unknown);"
fi
fp+="ver:${VERSION_TAG:-unknown};"
if command_exists md5sum; then
printf '%s' "$fp" | md5sum | cut -c1-16
elif command_exists sha256sum; then
printf '%s' "$fp" | sha256sum | cut -c1-16
else
printf '%s' "$fp" | cksum | cut -d' ' -f1
fi
}
validate_aot_cache() {
local cache_path="$1"
local fp_file="${cache_path}.fingerprint"
[ -f "$cache_path" ] || return 1
if [ ! -s "$cache_path" ]; then
log "AOT: Cache file is empty, removing."
rm -f "$cache_path" "$fp_file"
return 1
fi
local expected_fp stored_fp=""
expected_fp=$(compute_aot_fingerprint)
[ -f "$fp_file" ] && stored_fp=$(cat "$fp_file" 2>/dev/null || true)
if [ "$stored_fp" != "$expected_fp" ]; then
log "AOT: Fingerprint mismatch (stored=${stored_fp:-<none>} expected=${expected_fp})."
log "AOT: JAR, JDK, arch, or flags changed, removing stale cache."
rm -f "$cache_path" "$fp_file"
return 1
fi
log "AOT: Cache fingerprint valid (${expected_fp})"
return 0
}
save_aot_fingerprint() {
local cache_path="$1"
local fp_file="${cache_path}.fingerprint"
compute_aot_fingerprint > "$fp_file" 2>/dev/null || true
chmod 644 "$fp_file" 2>/dev/null || true
}
# ---------- Memory Detection ----------
CONTAINER_MEM_MB=$(detect_container_memory_mb)
JVM_PROFILE="${STIRLING_JVM_PROFILE:-balanced}"
compute_dynamic_memory "$CONTAINER_MEM_MB" "$JVM_PROFILE"
MEMORY_FLAGS="-XX:InitialRAMPercentage=${DYNAMIC_INITIAL_RAM_PCT} -XX:MaxRAMPercentage=${DYNAMIC_MAX_RAM_PCT} -XX:MaxMetaspaceSize=${DYNAMIC_MAX_METASPACE}m"
# ---------- Compressed Oops Detection ----------
# Only needed for AOT cache consistency (training and runtime must agree on this flag).
if [ "$AOT_ENABLED" = "true" ]; then
if [ "$CONTAINER_MEM_MB" -gt 0 ] 2>/dev/null; then
MAX_HEAP_MB=$((CONTAINER_MEM_MB * DYNAMIC_MAX_RAM_PCT / 100))
if [ "$MAX_HEAP_MB" -ge 31744 ]; then
COMPRESSED_OOPS_FLAG="-XX:-UseCompressedOops"
else
COMPRESSED_OOPS_FLAG="-XX:+UseCompressedOops"
fi
else
COMPRESSED_OOPS_FLAG="-XX:+UseCompressedOops"
fi
fi
# ---------- JVM Profile Selection ----------
# Resolve JAVA_BASE_OPTS from profile system or user override.
# Priority: JAVA_BASE_OPTS (explicit override) > STIRLING_JVM_PROFILE > fallback defaults
if [ -z "${JAVA_BASE_OPTS:-}" ]; then
case "$JVM_PROFILE" in
performance)
if [ -n "${_JVM_OPTS_PERFORMANCE:-}" ]; then
JAVA_BASE_OPTS="${_JVM_OPTS_PERFORMANCE}"
log "JVM profile: performance (Shenandoah generational)"
else
JAVA_BASE_OPTS="${_JVM_OPTS_BALANCED:-}"
log "Performance profile not available in this image; falling back to balanced"
fi
;;
*)
if [ -n "${_JVM_OPTS_BALANCED:-}" ]; then
JAVA_BASE_OPTS="${_JVM_OPTS_BALANCED}"
log "JVM profile: balanced (G1GC)"
else
log "JAVA_BASE_OPTS and profiles unset; applying fallback defaults."
JAVA_BASE_OPTS="-XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/stirling-pdf/heap_dumps -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:G1HeapRegionSize=4m -XX:G1PeriodicGCInterval=60000 -XX:+UseStringDeduplication -XX:+UseCompactObjectHeaders -XX:+ExplicitGCInvokesConcurrent -Dspring.threads.virtual.enabled=true"
fi
;;
esac
# Strip any hardcoded memory/CDS/AOT flags from the profile (managed dynamically)
JAVA_BASE_OPTS=$(echo "$JAVA_BASE_OPTS" | sed -E \
's/-XX:InitialRAMPercentage=[^ ]*//g;
s/-XX:MinRAMPercentage=[^ ]*//g;
s/-XX:MaxRAMPercentage=[^ ]*//g;
s/-XX:MaxMetaspaceSize=[^ ]*//g;
s/-XX:SharedArchiveFile=[^ ]*//g;
s/-Xshare:(auto|on|off)//g;
s/-XX:AOTCache=[^ ]*//g;
s/-XX:AOTMode=[^ ]*//g;
s/-XX:AOTConfiguration=[^ ]*//g')
# Append computed dynamic memory flags
JAVA_BASE_OPTS="${JAVA_BASE_OPTS} ${MEMORY_FLAGS}"
else
# JAVA_BASE_OPTS explicitly set by user or Dockerfile
# Only add dynamic memory if not already present
if ! echo "$JAVA_BASE_OPTS" | grep -q 'MaxRAMPercentage'; then
JAVA_BASE_OPTS="${JAVA_BASE_OPTS} ${MEMORY_FLAGS}"
log "Appended dynamic memory flags to JAVA_BASE_OPTS"
else
log "JAVA_BASE_OPTS already contains memory flags; keeping user values"
fi
fi
# Check if Project Lilliput is supported (standard in Java 25+, but experimental on some ARM builds)
# COMPACT_HEADERS_FLAG is used by generate_aot_cache() to ensure training/runtime consistency.
if java -XX:+UseCompactObjectHeaders -version >/dev/null 2>&1; then
COMPACT_HEADERS_FLAG="-XX:+UseCompactObjectHeaders"
# Only append if not already present in JAVA_BASE_OPTS
case "${JAVA_BASE_OPTS}" in
*UseCompactObjectHeaders*) ;;
*)
log "JVM supports Compact Object Headers ($(uname -m)). Enabling Project Lilliput..."
JAVA_BASE_OPTS="${JAVA_BASE_OPTS} -XX:+UseCompactObjectHeaders"
;;
esac
else
COMPACT_HEADERS_FLAG=""
log "JVM does not support Compact Object Headers on $(uname -m). Skipping Project Lilliput flags."
fi
# ---------- AOT Support Check ----------
AOT_SUPPORTED=false
if [ "$AOT_ENABLED" = "true" ]; then
AOT_SUPPORTED=true
if ! java -XX:AOTMode=off -version >/dev/null 2>&1; then
log "AOT: JVM on $(uname -m) does not support -XX:AOTMode, AOT cache disabled"
AOT_SUPPORTED=false
fi
fi
# ---------- Clean deprecated/invalid JVM flags ----------
# Remove UseCompressedClassPointers (deprecated in Java 25+ with Lilliput)
JAVA_BASE_OPTS=$(echo "$JAVA_BASE_OPTS" | sed -E 's/-XX:[+-]UseCompressedClassPointers//g')
# Manage UseCompressedOops explicitly only when AOT is enabled (training/runtime must agree)
if [ "$AOT_ENABLED" = "true" ]; then
JAVA_BASE_OPTS=$(echo "$JAVA_BASE_OPTS" | sed -E 's/-XX:[+-]UseCompressedOops//g')
JAVA_BASE_OPTS="${JAVA_BASE_OPTS} ${COMPRESSED_OOPS_FLAG}"
fi
# ---------- AOT Cache Management (Project Leyden) ----------
AOT_CACHE="/configs/cache/stirling.aot"
AOT_GENERATE_BACKGROUND=false
if [ "$AOT_ENABLED" = "true" ]; then
# Strip any legacy CDS/AOT references from base opts (managed dynamically here)
JAVA_BASE_OPTS=$(echo "$JAVA_BASE_OPTS" | sed -E \
's/-XX:SharedArchiveFile=[^ ]*//g;
s/-Xshare:(auto|on|off)//g;
s/-XX:AOTCache=[^ ]*//g')
if [ "$AOT_SUPPORTED" = false ]; then
log "AOT: Not supported on this JVM/platform, skipping"
elif validate_aot_cache "$AOT_CACHE"; then
log "AOT cache valid: $AOT_CACHE"
JAVA_BASE_OPTS="${JAVA_BASE_OPTS} -XX:AOTCache=${AOT_CACHE}"
rm -f /app/stirling.jsa /app/stirling.aot /app/stirling.aot.fingerprint 2>/dev/null || true
else
log "No valid AOT cache found. Will generate in background after app starts."
AOT_GENERATE_BACKGROUND=true
fi
fi
# Collapse duplicate whitespace
JAVA_BASE_OPTS=$(echo "$JAVA_BASE_OPTS" | tr -s ' ')
# ---------- JAVA_OPTS ----------
# Configure Java runtime options.
export JAVA_TOOL_OPTIONS="${JAVA_BASE_OPTS:-} ${JAVA_CUSTOM_OPTS:-}"
# Prepend headless flag only if not already present
case "${JAVA_TOOL_OPTIONS}" in
*java.awt.headless*) ;;
*) export JAVA_TOOL_OPTIONS="-Djava.awt.headless=true ${JAVA_TOOL_OPTIONS}" ;;
esac
log "running with JAVA_TOOL_OPTIONS=${JAVA_TOOL_OPTIONS}"
log "Running Stirling PDF with DISABLE_ADDITIONAL_FEATURES=${DISABLE_ADDITIONAL_FEATURES:-} and VERSION_TAG=${VERSION_TAG:-<unset>}"
# ---------- UMASK ----------
# Set default permissions mask.
UMASK_VAL="${UMASK:-022}"
umask "$UMASK_VAL" 2>/dev/null || umask 022
# ---------- XDG_RUNTIME_DIR ----------
# Create the runtime directory, respecting UID/GID settings.
RUNTIME_USER="stirlingpdfuser"
if id -u "$RUNTIME_USER" >/dev/null 2>&1; then
RUID="$(id -u "$RUNTIME_USER")"
RGRP="$(id -gn "$RUNTIME_USER")"
else
RUID="$(id -u)"
RGRP="$(id -gn)"
RUNTIME_USER="$(id -un)"
fi
CURRENT_USER="$(id -un)"
CURRENT_UID="$(id -u)"
export XDG_RUNTIME_DIR="/tmp/xdg-${RUID}"
mkdir -p "${XDG_RUNTIME_DIR}" || true
if [ "$(id -u)" -eq 0 ]; then
chown "${RUNTIME_USER}:${RGRP}" "${XDG_RUNTIME_DIR}" 2>/dev/null || true
fi
chmod 700 "${XDG_RUNTIME_DIR}" 2>/dev/null || true
log "XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR}"
# ---------- Optional ----------
# Disable advanced HTML operations if required.
if [[ "${INSTALL_BOOK_AND_ADVANCED_HTML_OPS:-false}" == "true" && "${FAT_DOCKER:-true}" != "true" ]]; then
log "issue with calibre in current version, feature currently disabled on Stirling-PDF"
fi
# Download security JAR in non-fat builds.
if [[ "${FAT_DOCKER:-true}" != "true" && -x /scripts/download-security-jar.sh ]]; then
/scripts/download-security-jar.sh || true
fi
# ---------- UID/GID remap ----------
# Remap user/group IDs to match container runtime settings.
if [ "$(id -u)" -eq 0 ]; then
if id -u stirlingpdfuser >/dev/null 2>&1; then
if [ -n "${PUID:-}" ] && [ "$PUID" != "$(id -u stirlingpdfuser)" ]; then
usermod -o -u "$PUID" stirlingpdfuser || true
chown stirlingpdfuser:stirlingpdfgroup "${XDG_RUNTIME_DIR}" 2>/dev/null || true
fi
fi
if getent group stirlingpdfgroup >/dev/null 2>&1; then
if [ -n "${PGID:-}" ] && [ "$PGID" != "$(getent group stirlingpdfgroup | cut -d: -f3)" ]; then
groupmod -o -g "$PGID" stirlingpdfgroup || true
fi
fi
fi
# ---------- Permissions ----------
# Ensure required directories exist and set correct permissions.
log "Setting permissions..."
mkdir -p /tmp/stirling-pdf /tmp/stirling-pdf/heap_dumps /logs /configs /configs/heap_dumps /configs/cache /customFiles /pipeline || true
CHOWN_PATHS=("$HOME" "/logs" "/scripts" "/configs" "/customFiles" "/pipeline" "/tmp/stirling-pdf" "/app.jar")
[ -d /usr/share/fonts/truetype ] && CHOWN_PATHS+=("/usr/share/fonts/truetype")
CHOWN_OK=true
for p in "${CHOWN_PATHS[@]}"; do
if [ -e "$p" ]; then
chown -R "stirlingpdfuser:stirlingpdfgroup" "$p" 2>/dev/null || CHOWN_OK=false
chmod -R 755 "$p" 2>/dev/null || true
fi
done
# ---------- Xvfb ----------
# Start a virtual framebuffer for GUI-based LibreOffice interactions.
if command_exists Xvfb; then
log "Starting Xvfb on :99"
Xvfb :99 -screen 0 1024x768x24 -ac +extension GLX +render -noreset > /dev/null 2>&1 &
export DISPLAY=:99
# Brief pause so Xvfb accepts connections before unoserver tries to attach
sleep 1
else
log "Xvfb not installed; skipping virtual display setup"
fi
# ---------- unoserver ----------
# Start LibreOffice UNO server for document conversions.
# Java and unoserver start in parallel, do NOT block here waiting for readiness.
# Readiness is verified after Java is launched; the watchdog handles any restarts.
UNOSERVER_BIN="$(command -v unoserver || true)"
UNOCONVERT_BIN="$(command -v unoconvert || true)"
UNOPING_BIN="$(command -v unoping || true)"
if [ -n "$UNOSERVER_BIN" ] && [ -n "$UNOCONVERT_BIN" ]; then
LIBREOFFICE_PROFILE="${HOME:-/home/${RUNTIME_USER}}/.libreoffice_uno_${RUID}"
run_as_runtime_user mkdir -p "$LIBREOFFICE_PROFILE"
start_unoserver_pool
log "unoserver pool started (Profile: $LIBREOFFICE_PROFILE), Java starting in parallel"
else
log "unoserver/unoconvert not installed; skipping UNO setup"
fi
# ---------- Java ----------
# Start Stirling PDF Java application immediately (parallel with unoserver startup).
log "Starting Stirling PDF"
JAVA_CMD=(
java
-Dfile.encoding=UTF-8
-Djava.io.tmpdir=/tmp/stirling-pdf
)
if [ -f "/app.jar" ]; then
JAVA_CMD+=("-jar" "/app.jar")
elif [ -f "/app/app.jar" ]; then
# Spring Boot 4 layered JAR structure (exploded via extract --layers).
# Use -cp (not -jar) so the classpath matches the AOT cache exactly.
JAVA_CMD+=("-cp" "/app/app.jar:/app/lib/*" "stirling.software.SPDF.SPDFApplication")
else
# Legacy fallback for Spring Boot 3 layered layout
export JAVA_MAIN_CLASS=org.springframework.boot.loader.launch.JarLauncher
JAVA_CMD+=("org.springframework.boot.loader.launch.JarLauncher")
fi
if [ "$CURRENT_USER" = "$RUNTIME_USER" ]; then
"${JAVA_CMD[@]}" &
elif [ "$CURRENT_UID" -eq 0 ] && [ -n "$SU_EXEC_BIN" ]; then
"$SU_EXEC_BIN" "$RUNTIME_USER" "${JAVA_CMD[@]}" &
else
warn_switch_user_once
"${JAVA_CMD[@]}" &
fi
JAVA_PID=$!
# ---------- Unoserver Readiness + Watchdog ----------
# Now that Java is running, check unoserver readiness and start the watchdog.
# Runs in the main shell (not a subshell) so UNOSERVER_PIDS/PORTS arrays are accessible.
# Java handles unoserver being temporarily unavailable, no fatal exit on timeout.
if [ "${#UNOSERVER_PORTS[@]}" -gt 0 ]; then
log "Waiting for unoserver (Java already starting in parallel)..."
UNOSERVER_READY=false
for _ in {1..30}; do
if check_unoserver_ready "silent"; then
log "unoserver is ready!"
UNOSERVER_READY=true
break
fi
sleep 1
done
start_unoserver_watchdog
if [ "$UNOSERVER_READY" = false ] && ! check_unoserver_ready; then
log "WARNING: unoserver not ready after 30s. Watchdog will manage restarts. Document conversion may be temporarily unavailable."
fi
fi
# ---------- Background AOT Cache Generation ----------
# On first boot (no valid cache), generate the AOT cache in the background so the app
# starts immediately. The cache is ready for the NEXT boot (15-25% faster startup).
AOT_GEN_PID=""
if [ "$AOT_GENERATE_BACKGROUND" = true ]; then
# ARM devices need more memory for training due to JIT differences
_aot_min_mem=768
if [ "$(uname -m)" = "aarch64" ]; then
_aot_min_mem=1024
fi
if [ "$CONTAINER_MEM_MB" -gt "$_aot_min_mem" ] || [ "$CONTAINER_MEM_MB" -eq 0 ]; then
(
# Wait for Spring Boot to finish initializing before competing for CPU/memory.
# ARM devices (Raspberry Pi 4, Ampere) need extra time, 90s vs 45s on x86_64.
_startup_wait=45
if [ "$(uname -m)" = "aarch64" ]; then
_startup_wait=90
log "AOT: ARM, waiting ${_startup_wait}s for app stabilization before training"
fi
sleep "$_startup_wait"
if ! kill -0 "$JAVA_PID" 2>/dev/null; then
log "AOT: Main process exited; skipping cache generation."
exit 0
fi
_attempt=1
_max_attempts=2
while [ "$_attempt" -le "$_max_attempts" ]; do
log "AOT: Background cache generation attempt ${_attempt}/${_max_attempts}..."
_gen_rc=0
if [ -f /app/app.jar ] && [ -d /app/lib ]; then
generate_aot_cache "$AOT_CACHE" \
-cp "/app/app.jar:/app/lib/*" stirling.software.SPDF.SPDFApplication || _gen_rc=$?
elif [ -f /app.jar ]; then
generate_aot_cache "$AOT_CACHE" -jar /app.jar || _gen_rc=$?
elif [ -d /app/BOOT-INF ]; then
# Spring Boot exploded layer layout, mirror the exact JAVA_CMD classpath
generate_aot_cache "$AOT_CACHE" \
-cp /app org.springframework.boot.loader.launch.JarLauncher || _gen_rc=$?
else
log "AOT: Cannot determine JAR layout; skipping cache generation."
exit 0
fi
if [ "$_gen_rc" -eq 0 ] && [ -f "$AOT_CACHE" ]; then
log "AOT: Cache ready for next boot!"
exit 0
fi
log "AOT: Attempt ${_attempt} failed (rc=${_gen_rc})"
_attempt=$((_attempt + 1))
if [ "$_attempt" -le "$_max_attempts" ]; then
if ! kill -0 "$JAVA_PID" 2>/dev/null; then
log "AOT: Main process exited during retry; aborting."
exit 0
fi
log "AOT: Retrying in 30s..."
sleep 30
fi
done
log "AOT: All attempts failed. App runs normally without cache."
log "AOT: To disable, set STIRLING_AOT_ENABLE=false (or omit it, default is off)"
) &
AOT_GEN_PID=$!
log "AOT: Background generation scheduled (PID $AOT_GEN_PID, arch=$(uname -m))"
else
log "AOT: Container memory (${CONTAINER_MEM_MB}MB) below minimum (${_aot_min_mem}MB on $(uname -m)), skipping cache generation"
fi
fi
wait "$JAVA_PID" || true
exit_code=$?
case "$exit_code" in
0) log "Stirling PDF exited normally." ;;
137) log "Stirling PDF was OOM-killed (exit 137). Check container memory limits." ;;
143) log "Stirling PDF terminated by SIGTERM (normal orchestrator shutdown)." ;;
*) log "Stirling PDF exited with code ${exit_code}." ;;
esac
# Propagate exit code so orchestrators can detect crashes vs clean shutdowns
exit "${exit_code}"