#!/bin/sh # Heartbeat liveness check for pulse-worker and pulse-agent. # # Full contract: docs/operations/WORKER_AGENT_HEALTHCHECK_CONTRACT.md # # Summary: the running process must write/touch $PULSE_HEARTBEAT_FILE at # least every ~10s as long as its main loop is alive and making forward # progress. This script treats the file's mtime as the source of truth # (not its contents) so it never needs to parse a timestamp format. # # `docker compose up` (non-swarm) does not restart a container solely # because its HEALTHCHECK reports "unhealthy" -- that status is only # informational under plain Compose. To actually recover a hung-but-alive # process without an external watcher (which would need Docker socket # access, forbidden host-wide per ADR-0005), this script self-terminates # PID 1 once staleness is confirmed, which turns "unhealthy" into a real # container exit that `restart: unless-stopped` then recovers from. # # It deliberately does NOT self-terminate merely because the heartbeat file # is absent shortly after container start (that's the normal boot path, # handled separately below using the entrypoint-recorded start time), which # avoids a restart loop on legitimately slow startups (DB dial, migrations # wait, etc.). set -eu HEARTBEAT_FILE="${PULSE_HEARTBEAT_FILE:-/tmp/healthy}" STARTED_AT_FILE="${PULSE_STARTED_AT_FILE:-/tmp/.pulse-started-at}" MAX_AGE_SECONDS="${PULSE_HEARTBEAT_MAX_AGE_SECONDS:-45}" now=$(date +%s) self_restart() { reason="$1" echo "pulse-healthcheck: $reason; forcing container exit for restart" >&2 # Same-UID SIGKILL to PID 1 requires no capabilities (cap_drop: [ALL] is # fine): standard POSIX signal permission only requires a matching UID, # not CAP_KILL, when sender and target share a UID. kill -9 1 2>/dev/null || true } if [ ! -f "$HEARTBEAT_FILE" ]; then started_at=$(cat "$STARTED_AT_FILE" 2>/dev/null || echo "$now") uptime=$((now - started_at)) if [ "$uptime" -gt "$MAX_AGE_SECONDS" ]; then self_restart "no heartbeat ${uptime}s after container start" else echo "pulse-healthcheck: heartbeat file not yet written (uptime ${uptime}s)" >&2 fi exit 1 fi mtime=$(stat -c %Y "$HEARTBEAT_FILE" 2>/dev/null) || { echo "pulse-healthcheck: cannot stat heartbeat file $HEARTBEAT_FILE" >&2 exit 1 } age=$((now - mtime)) if [ "$age" -gt "$MAX_AGE_SECONDS" ]; then self_restart "heartbeat stale (${age}s > ${MAX_AGE_SECONDS}s)" exit 1 fi exit 0