168 lines
7.1 KiB
Bash
Executable file
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
|