gem/scripts/run-when-host-clean.sh
2026-08-30 18:24:55 -06:00

168 lines
7.1 KiB
Bash
Executable file

#!/bin/sh
# Wait for a sustained quiet macOS window, then replace this process with COMMAND.
set -eu
if [ "$#" -lt 3 ] || [ "$2" != -- ]; then
echo "usage: $0 RECEIPT.ndjson -- COMMAND [ARG ...]" >&2
exit 2
fi
receipt=$1
shift 2
interval=${GEM_CLEAN_HOST_INTERVAL_SECONDS:-30}
consecutive_required=${GEM_CLEAN_HOST_CONSECUTIVE_SAMPLES:-3}
max_wait=${GEM_CLEAN_HOST_MAX_WAIT_SECONDS:-28800}
max_load=${GEM_CLEAN_HOST_MAX_LOAD_PER_CPU:-1.0}
max_busy=${GEM_CLEAN_HOST_MAX_SHORT_WINDOW_BUSY_CPU_PERCENT:-10.0}
fixture=${GEM_CLEAN_HOST_FIXTURE_FILE:-}
[ ! -e "$receipt" ] && [ ! -L "$receipt" ] || {
echo "gem: clean-host receipt already exists: $receipt" >&2
exit 2
}
[ -d "$(dirname "$receipt")" ] || {
echo "gem: clean-host receipt parent does not exist: $(dirname "$receipt")" >&2
exit 2
}
case $interval in ''|*[!0-9]*) echo "gem: invalid sample interval" >&2; exit 2 ;; esac
case $consecutive_required in
''|*[!0-9]*) echo "gem: invalid consecutive sample count" >&2; exit 2 ;;
esac
case $max_wait in ''|*[!0-9]*) echo "gem: invalid maximum wait" >&2; exit 2 ;; esac
[ "$consecutive_required" -gt 0 ] && [ "$max_wait" -gt 0 ] || {
echo "gem: clean-host counts and maximum wait must be positive" >&2
exit 2
}
for value in "$max_load" "$max_busy"; do
awk -v value="$value" 'BEGIN {
exit !(value ~ /^[0-9]+([.][0-9]+)?$/ && value > 0)
}' || { echo "gem: clean-host thresholds must be positive numbers" >&2; exit 2; }
done
if ! command -v jq >/dev/null 2>&1; then
echo "gem: clean-host launcher requires jq" >&2
exit 2
fi
if [ -n "$fixture" ]; then
[ -f "$fixture" ] || { echo "gem: clean-host fixture is missing" >&2; exit 2; }
exec 3< "$fixture"
elif [ "$(uname -s)" != Darwin ] || ! command -v sysctl >/dev/null 2>&1 ||
! command -v pmset >/dev/null 2>&1 || ! command -v top >/dev/null 2>&1; then
echo "gem: clean-host launcher requires macOS host-state tools" >&2
exit 2
fi
if [ ! -x "$1" ] && ! command -v "$1" >/dev/null 2>&1; then
echo "gem: clean-host target is not executable: $1" >&2
exit 2
fi
start_epoch=$(date +%s)
attempt=0
consecutive=0
while :; do
attempt=$((attempt + 1))
timestamp=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
if [ -n "$fixture" ]; then
if ! IFS= read -r sample <&3; then
echo "gem: clean-host fixture exhausted before a clean window" >&2
exit 75
fi
else
load_average=$(sysctl -n vm.loadavg)
load_1m=$(printf '%s\n' "$load_average" | awk '{print $2}')
logical_cpus=$(sysctl -n hw.logicalcpu)
short_busy=$(LC_ALL=C top -l 2 -s 1 -n 0 -stats pid,cpu,command |
awk -F'[:,%]' '/^CPU usage:/ {idle=$6} END {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", idle)
if (idle !~ /^[0-9]+([.][0-9]+)?$/) exit 1
printf "%.3f", 100 - idle
}') || {
echo "gem: could not sample short-window macOS CPU state" >&2
exit 1
}
power_state=$(LC_ALL=C pmset -g batt 2>&1 || true)
thermal_state=$(LC_ALL=C pmset -g therm 2>&1 || true)
ac_power=false
case $power_state in *"Now drawing from 'AC Power'"*) ac_power=true ;; esac
no_thermal_warning=false
if printf '%s\n' "$thermal_state" |
grep -Fq 'No thermal warning level has been recorded' &&
printf '%s\n' "$thermal_state" |
grep -Fq 'No performance warning level has been recorded'; then
no_thermal_warning=true
elif printf '%s\n' "$thermal_state" |
grep -Eq 'CPU_Scheduler_Limit[[:space:]]*=[[:space:]]*100' &&
printf '%s\n' "$thermal_state" |
grep -Eq 'CPU_Speed_Limit[[:space:]]*=[[:space:]]*100'; then
no_thermal_warning=true
fi
top_processes=$(ps -Ao pid,pcpu,pmem,etime,comm -r | sed -n '1,24p')
sample=$(jq -cn --arg load_average "$load_average" --argjson load_1m "$load_1m" \
--argjson logical_cpus "$logical_cpus" --argjson short_busy "$short_busy" \
--arg power_state "$power_state" --argjson ac_power "$ac_power" \
--arg thermal_state "$thermal_state" \
--argjson no_thermal_warning "$no_thermal_warning" \
--arg top_processes "$top_processes" \
'{load_average: $load_average, load_1m: $load_1m,
logical_cpus: $logical_cpus, short_window_busy_cpu_percent: $short_busy,
power_state: $power_state, ac_power: $ac_power,
thermal_state: $thermal_state, no_thermal_warning: $no_thermal_warning,
top_processes: $top_processes}')
fi
if ! printf '%s\n' "$sample" | jq -e '
(.load_1m | type) == "number" and .load_1m >= 0 and
(.logical_cpus | type) == "number" and .logical_cpus > 0 and
(.short_window_busy_cpu_percent | type) == "number" and
.short_window_busy_cpu_percent >= 0 and
(.ac_power | type) == "boolean" and
(.no_thermal_warning | type) == "boolean"
' >/dev/null; then
echo "gem: invalid clean-host sample" >&2
exit 2
fi
load_per_cpu=$(printf '%s\n' "$sample" |
jq -r '.load_1m / .logical_cpus')
clean=true
awk -v actual="$load_per_cpu" -v maximum="$max_load" \
'BEGIN {exit !(actual <= maximum)}' || clean=false
short_busy=$(printf '%s\n' "$sample" | jq -r '.short_window_busy_cpu_percent')
awk -v actual="$short_busy" -v maximum="$max_busy" \
'BEGIN {exit !(actual <= maximum)}' || clean=false
[ "$(printf '%s\n' "$sample" | jq -r '.ac_power')" = true ] || clean=false
[ "$(printf '%s\n' "$sample" | jq -r '.no_thermal_warning')" = true ] || clean=false
if [ "$clean" = true ]; then
consecutive=$((consecutive + 1))
status=pass
else
consecutive=0
status=fail
fi
printf '%s\n' "$sample" | jq -c \
--arg timestamp "$timestamp" --arg status "$status" \
--argjson attempt "$attempt" --argjson load_per_cpu "$load_per_cpu" \
--argjson max_load "$max_load" --argjson max_busy "$max_busy" \
--argjson consecutive "$consecutive" \
--argjson required "$consecutive_required" '
{schema_version: 1, timestamp_utc: $timestamp, attempt: $attempt,
status: $status, load_average: (.load_average // null), load_1m,
logical_cpus, load_per_logical_cpu: $load_per_cpu,
max_load_per_logical_cpu: $max_load,
short_window_busy_cpu_percent,
max_short_window_busy_cpu_percent: $max_busy,
power_state: (.power_state // null), ac_power,
thermal_state: (.thermal_state // null), no_thermal_warning,
top_processes: (.top_processes // null),
consecutive_clean_samples: $consecutive,
required_consecutive_clean_samples: $required}' >> "$receipt"
if [ "$consecutive" -ge "$consecutive_required" ]; then
echo "gem: sustained clean-host window found after $attempt samples" >&2
exec "$@"
fi
now_epoch=$(date +%s)
if [ $((now_epoch - start_epoch)) -ge "$max_wait" ]; then
echo "gem: timed out waiting for a sustained clean-host window" >&2
exit 75
fi
sleep "$interval"
done