#!/usr/bin/env bash # infra/bin/fly-watchdog — fly-watchdog.service, run every 60s by # fly-watchdog.timer. Runs inside the container as User=fly (restart # actions go through `sudo systemctl` — see the NOPASSWD sudoers note in # docs/runbook.md; this script never runs as root itself). # # Implements the seven checks in docs/design/infra.md section 3, in order, # plus an eighth (encoder backend) from docs/design/gpu.md section 5, a # ninth (capture freeze) from infra/docs/capture-freeze.md and a tenth (loop # suspected) from infra/docs/macros-traps.md, # each with its own remediation restarting only the failed unit -- except the # eighth and the tenth, which deliberately take no action at all -- plus the # per-unit consecutive-failure counters, escalation, and the two # notification channels (journal + node_exporter textfile) from that # section. See also docs/control-api.md (/healthz) and infra.md section 5 # (the /metrics and /status.json shapes on the read-only listener). # # Ports (feed-protocol.md / control-api.md are the binding contracts, # superseding infra.md's own numbers): control API 127.0.0.1:7401, # feed 127.0.0.1:7400. flysim's separate read-only metrics/status listener # on :9101 is NOT superseded by those two docs and keeps infra.md's number. set -euo pipefail log_err() { # Stable prefix per docs/design/infra.md section 3, so `journalctl # -g fly-watchdog:` finds every escalation across every unit. echo "fly-watchdog: $*" | systemd-cat -t fly-watchdog -p err } log_info() { echo "fly-watchdog: $*" | systemd-cat -t fly-watchdog -p info } : "${FLY_CONTROL_URL:=http://127.0.0.1:7401}" : "${FLY_METRICS_URL:=http://127.0.0.1:9101}" : "${FLY_STATE_HOT:=/run/fly/state}" : "${FLY_MEDIA_DIR:=/srv/fly/media}" : "${MEDIAMTX_API:=http://127.0.0.1:9997}" : "${FLY_PROGRESS_FLYCAST:=/run/fly/flycast.progress}" : "${FLY_PROGRESS_FLYPUSH:=/run/fly/flypush.progress}" : "${WD_RUN_DIR:=/run/fly/wd}" # tmpfs: consecutive-fail counters, cleared on reboot : "${WD_STATE_DIR:=/var/lib/fly/wd}" # persistent: reboot-rate gate : "${TEXTFILE_DIR:=/var/lib/node_exporter/textfile}" : "${RETENTION_BIN:=/opt/fly/bin/fly-retention}" # Written by bin/flycast-launch, which is the only thing that knows which # encoder actually opened (docs/design/gpu.md section 5). : "${FLY_ENCODER_METRIC:=${TEXTFILE_DIR}/fly_encoder.prom}" : "${FLY_ENV_FILE:=/etc/fly/fly.env}" # --- check 9 (capture freeze) inputs, infra/docs/capture-freeze.md --------- : "${FLY_REC_DIR:=/srv/fly/media/rec}" # flycast's 10-minute mpegts segments : "${FFMPEG:=/usr/bin/ffmpeg}" : "${WD_FREEZE_INTERVAL:=300}" # probe every 5 minutes, not every pass : "${WD_FREEZE_SECONDS:=3}" # decode the last 3 s of the newest segment : "${WD_FREEZE_IDENTICAL:=80}" # >= this many identical frames of ~90 is a freeze : "${WD_FREEZE_MIN_FRAMES:=60}" # fewer decoded frames than this: inconclusive : "${WD_FREEZE_COOLDOWN:=1800}" # never restart flycast for this more than once per 30 min : "${WD_FREEZE_SEGMENT_MAX_AGE:=120}" # newest segment must be this fresh to be worth probing : "${FLYCAST_CPUSET_DROPIN:=/etc/systemd/system/flycast.service.d/cpuset.conf}" # --- check 10 (loop suspected) inputs, infra/docs/macros-traps.md ---------- : "${FLY_STATE_DIR:=/srv/fly/state}" : "${FLY_EVENT_LOG:=${FLY_STATE_DIR}/events.jsonl}" : "${FLY_STATUS_URL:=${FLY_METRICS_URL}/status.json}" : "${WD_LOOP_INTERVAL:=300}" # probe every 5 minutes, like check 9 : "${WD_LOOP_WINDOW_MS:=600000}" # 10 BRAIN minutes of macro events : "${WD_LOOP_TAIL_LINES:=20000}" # lines of events.jsonl worth reading back : "${WD_LOOP_MAX_PERIOD:=8}" # longest cycle to look for (trap_hunt's own cap) : "${WD_LOOP_MAX_DISTINCT:=4}" # "a short macro sequence": at most this many names (4: the Viridian cycle had four) : "${WD_LOOP_MIN_REPEATS:=20}" # ... repeating at least this many times : "${WD_LOOP_DOMINANCE_PCT:=95}" # or one macro being this share of the window : "${WD_LOOP_MIN_EVENTS:=20}" # floor under the dominance rule (see check 10) : "${WD_LOOP_STALL_PCT:=90}" # decisions that ended refused/blocked/timeout: this share is a stall (row 57) : "${WD_LOOP_REPORT:=${WD_RUN_DIR}/loop.json}" mkdir -p "$WD_RUN_DIR" "$WD_STATE_DIR" # The textfile directory is a notification channel, not a prerequisite. It is # root-owned ground (/var/lib/node_exporter) that this script, running as # `fly`, cannot create — and when it was in the `mkdir -p` line above, a # missing directory made `set -e` kill the whole pass at line one: no checks, # no restarts, no journal alarm, silently, every 60 s. Measured exactly that # way on the P0 spike run 2, where the directory did not exist at all. # `config/fly-tmpfiles.conf` creates it now; if it is still missing or # unwritable, carry on with the journal channel and say so once per pass. TEXTFILE_OK=1 if ! { [ -d "$TEXTFILE_DIR" ] && [ -w "$TEXTFILE_DIR" ]; }; then mkdir -p "$TEXTFILE_DIR" 2>/dev/null || true fi if ! { [ -d "$TEXTFILE_DIR" ] && [ -w "$TEXTFILE_DIR" ]; }; then TEXTFILE_OK=0 fi now() { date +%s; } # --- per-unit consecutive-failure bookkeeping ------------------------------- # One line per failure (epoch seconds), appended on failure, truncated to # empty on success. This is the "consecutive" counter: any success resets # it, matching "three consecutive failures ... restarts the dependent # chain; five triggers a reboot". fails_file() { echo "${WD_RUN_DIR}/${1}.fails"; } record_ok() { : > "$(fails_file "$1")" } record_fail() { local unit="$1" now >> "$(fails_file "$1")" log_err "check failed: $unit" inc_counter "restarts_total" "$unit" } fail_count() { local f f="$(fails_file "$1")" [ -f "$f" ] && wc -l < "$f" | tr -d ' ' || echo 0 } fails_within_minutes() { # true if the current run of consecutive failures started within N # minutes of now (guards against "3 failures over 3 days" ever reading # as an escalation). local unit="$1" minutes="$2" f first f="$(fails_file "$unit")" [ -s "$f" ] || return 1 first="$(head -n1 "$f")" [ $(( $(now) - first )) -le $(( minutes * 60 )) ] } # --- textfile metrics -------------------------------------------------------- # Accumulator files, one counter per metric-name+labelset, summed into the # .prom file on every run. Atomic write via tmp+rename, the node_exporter # textfile convention. inc_counter() { local name="$1" unit="${2:-}" f f="${WD_RUN_DIR}/counter.${name}.${unit}" local v=0 [ -f "$f" ] && v="$(cat "$f")" echo $(( v + 1 )) > "$f" } get_counter() { local name="$1" unit="${2:-}" f f="${WD_RUN_DIR}/counter.${name}.${unit}" [ -f "$f" ] && cat "$f" || echo 0 } write_textfile_metrics() { if [ "$TEXTFILE_OK" -ne 1 ]; then log_err "textfile directory ${TEXTFILE_DIR} missing or unwritable; journal is the only notification channel this pass" return 0 fi local tmp="${TEXTFILE_DIR}/fly_watchdog.prom.$$" { echo "# HELP fly_watchdog_restarts_total Restarts issued because that unit's own check failed (cascade restarts from another unit's escalation are counted only in fly_watchdog_escalations_total, not here)." echo "# TYPE fly_watchdog_restarts_total counter" for unit in flysim flystage flycast mediamtx flypush flystage-web; do echo "fly_watchdog_restarts_total{unit=\"${unit}\"} $(get_counter restarts_total "$unit")" done # Check 7's own restart reason, kept as a separate labelset on the # same metric family rather than folded into the flypush line above: # a progress-stall restart (check 5) and an age-guard restart (check # 7) are different failure modes and dashboards/alerts want to tell # them apart. echo "fly_watchdog_restarts_total{unit=\"flypush\",reason=\"age\"} $(get_counter restarts_total_age flypush)" echo "# HELP fly_watchdog_escalations_total Chain restarts / reboots issued by fly-watchdog." echo "# TYPE fly_watchdog_escalations_total counter" echo "fly_watchdog_escalations_total $(get_counter escalations_total)" echo "# HELP fly_watchdog_disk_critical 1 when /srv/fly/media is above the 95% guard." echo "# TYPE fly_watchdog_disk_critical gauge" echo "fly_watchdog_disk_critical $(cat "${WD_RUN_DIR}/disk_critical" 2>/dev/null || echo 0)" echo "# HELP fly_watchdog_encoder_degraded 1 when flycast is running on a different video encoder than /etc/fly/fly.env asked for (i.e. the nvenc -> x264 fallback fired). -1 when the encoder state could not be read at all." echo "# TYPE fly_watchdog_encoder_degraded gauge" echo "fly_watchdog_encoder_degraded $(cat "${WD_RUN_DIR}/encoder_degraded" 2>/dev/null || echo -1)" # Check 9 (infra/docs/capture-freeze.md). fly_capture_identical_frames # is -1 until the first probe completes, the same "unknown, not # broken" convention fly_watchdog_encoder_degraded uses; a value at or # above 80 (of ~90) means the ENCODER OUTPUT is repeating frames, # which is the frozen broadcast this check exists to see. echo "# HELP fly_capture_freeze_restarts_total flycast restarts issued because the encoder output was repeating frames (capture freeze)." echo "# TYPE fly_capture_freeze_restarts_total counter" echo "fly_capture_freeze_restarts_total $(get_counter capture_freeze_restarts)" echo "# HELP fly_capture_identical_frames Identical consecutive frames found in the last 3 s of the newest segment by the most recent freeze probe (of roughly 90). -1 before the first probe." echo "# TYPE fly_capture_identical_frames gauge" echo "fly_capture_identical_frames $(cat "${WD_RUN_DIR}/freeze.identical" 2>/dev/null || echo -1)" # Check 10 (infra/docs/macros-traps.md). fly_loop_suspected is a # report, not an alarm on a broken process: a 1 means the fly is very # probably going in circles and a human or a review agent should look, # and NOTHING in this script acts on it. The three window gauges read # -1 until the first probe completes (the same "unknown, not broken" # convention as fly_watchdog_encoder_degraded), while the flag itself # starts at 0, because a 0/1 flag carrying -1 reads as a loop to every # alert expression that would ever use it. echo "# HELP fly_loop_suspected 1 when the last check-10 probe found a short macro cycle repeating, one macro dominating, or the decisions stalled (refused/blocked) or completing nothing, with no growth in the exploration count. Report only: the watchdog never restarts or presses anything for this." echo "# TYPE fly_loop_suspected gauge" echo "fly_loop_suspected $(cat "${WD_RUN_DIR}/loop.suspected" 2>/dev/null || echo 0)" echo "# HELP fly_loop_period Length in macro labels of the shortest repeating block found at the end of the window (0 when nothing repeats, -1 before the first probe)." echo "# TYPE fly_loop_period gauge" echo "fly_loop_period $(cat "${WD_RUN_DIR}/loop.period" 2>/dev/null || echo -1)" echo "# HELP fly_loop_repeats How many times that block repeats (0 when nothing repeats, -1 before the first probe)." echo "# TYPE fly_loop_repeats gauge" echo "fly_loop_repeats $(cat "${WD_RUN_DIR}/loop.repeats" 2>/dev/null || echo -1)" echo "# HELP fly_loop_distinct_macros Distinct macro names started in the window (-1 before the first probe)." echo "# TYPE fly_loop_distinct_macros gauge" echo "fly_loop_distinct_macros $(cat "${WD_RUN_DIR}/loop.distinct" 2>/dev/null || echo -1)" # Row 57: outcomes, not only starts. A pad whose one button refuses # every hold reads as one start and one name; these say what the # decisions came to (-1 before the first probe). echo "# HELP fly_loop_refused Macro presses refused (nothing pressed) in the last check-10 window (-1 before the first probe)." echo "# TYPE fly_loop_refused gauge" echo "fly_loop_refused $(cat "${WD_RUN_DIR}/loop.refused" 2>/dev/null || echo -1)" echo "# HELP fly_loop_blocked Macros that ended blocked or timed out in the last check-10 window (-1 before the first probe)." echo "# TYPE fly_loop_blocked gauge" echo "fly_loop_blocked $(cat "${WD_RUN_DIR}/loop.blocked" 2>/dev/null || echo -1)" echo "# HELP fly_loop_done Macros that ended done in the last check-10 window (-1 before the first probe)." echo "# TYPE fly_loop_done gauge" echo "fly_loop_done $(cat "${WD_RUN_DIR}/loop.done" 2>/dev/null || echo -1)" echo "# HELP fly_places_delta Growth in game.uniqueLocations (the exploration count) since the previous check-10 probe. -1 when there is no previous probe to compare against." echo "# TYPE fly_places_delta gauge" echo "fly_places_delta $(cat "${WD_RUN_DIR}/loop.places_delta" 2>/dev/null || echo -1)" # Section 13.1 (docs/design/macros.md), the operator on stream: "sometimes the # macro buttons disappear and everything just hangs there". An empty # pad is the doctrine working — nothing presses for the fly, so a scene # with no button waits — and it looks exactly like a hang, so it is # published and exported and NOTHING here acts on it. -1 before the # first probe and for a service that does not publish game.padEmptyMs. echo "# HELP fly_pad_empty_seconds Whole seconds the macro pad has had nothing on it in a playable scene, from game.padEmptyMs. 0 when something is bound or a macro is running; -1 before the first probe. Report only: the watchdog never restarts or presses anything for this." echo "# TYPE fly_pad_empty_seconds gauge" echo "fly_pad_empty_seconds $(cat "${WD_RUN_DIR}/loop.pad_empty_seconds" 2>/dev/null || echo -1)" echo "# HELP fly_watchdog_last_run_seconds Unix time of the last completed watchdog pass." echo "# TYPE fly_watchdog_last_run_seconds gauge" echo "fly_watchdog_last_run_seconds $(now)" } > "$tmp" mv -f "$tmp" "${TEXTFILE_DIR}/fly_watchdog.prom" } # --- restart / escalation ---------------------------------------------------- restart_unit() { local unit="$1" log_err "restarting ${unit}.service" sudo /usr/bin/systemctl restart "${unit}.service" || log_err "restart of ${unit}.service FAILED" } # escalate UNIT CHAIN_UNIT... — called after record_fail. Applies the # three/five consecutive-failure rules from section 3. escalate() { local unit="$1" shift local chain=("$@") restart_unit "$unit" if fails_within_minutes "$unit" 10 && [ "$(fail_count "$unit")" -ge 3 ]; then log_err "${unit}: 3+ consecutive failures within 10m, restarting dependent chain: ${chain[*]:-}" inc_counter "escalations_total" local dep for dep in "${chain[@]:-}"; do [ -n "$dep" ] && restart_unit "$dep" done fi if [ "$(fail_count "$unit")" -ge 5 ]; then maybe_reboot "$unit" fi } maybe_reboot() { local unit="$1" local last_reboot_file="${WD_STATE_DIR}/last-reboot" local history_file="${WD_STATE_DIR}/reboot-history" local n n="$(now)" if [ -f "$last_reboot_file" ]; then local last last="$(cat "$last_reboot_file")" if [ $(( n - last )) -lt 3600 ]; then log_err "${unit}: 5+ consecutive failures, but last reboot was $(( (n - last) / 60 ))m ago (<60m gate); alarming only" inc_counter "escalations_total" return 0 fi fi touch "$history_file" # Keep only reboot timestamps from the last 6 hours, then count them. awk -v cutoff="$(( n - 21600 ))" '$1 >= cutoff' "$history_file" > "${history_file}.tmp" 2>/dev/null || true mv -f "${history_file}.tmp" "$history_file" 2>/dev/null || true local recent recent="$(wc -l < "$history_file" 2>/dev/null | tr -d ' ')" recent="${recent:-0}" if [ "$recent" -ge 3 ]; then log_err "${unit}: 5+ consecutive failures AND 3+ reboots in 6h — hard stop, alarming only, NOT rebooting (a reboot loop on a host that also runs another service's production workload is worse than a dead demo)" inc_counter "escalations_total" return 0 fi log_err "${unit}: 5+ consecutive failures, rebooting (reboot #$(( recent + 1 )) in the last 6h)" inc_counter "escalations_total" echo "$n" >> "$history_file" echo "$n" > "$last_reboot_file" sudo /usr/sbin/reboot } is_active() { systemctl is-active --quiet "$1" } is_enabled() { systemctl is-enabled --quiet "$1" 2>/dev/null } # ============================================================================ # Check 1: flysim — /healthz 200 and hot-state mtime age < 30s. # ============================================================================ check_flysim() { local ok=1 if ! curl -fsS -o /dev/null "${FLY_CONTROL_URL}/healthz"; then ok=0 fi if [ -e "$FLY_STATE_HOT" ]; then local age age=$(( $(now) - $(stat -c %Y "$FLY_STATE_HOT" 2>/dev/null || echo 0) )) [ "$age" -ge 30 ] && ok=0 else ok=0 fi if [ "$ok" -eq 1 ]; then record_ok flysim else record_fail flysim escalate flysim flystage flycast fi } # ============================================================================ # Check 2: flystage — flysim's frames_sent_total / feed_clients from the # read-only metrics listener (infra.md section 5; not superseded by the # feed/control contracts). Flat frames counter across two passes, or zero # clients, means the page is dead/frozen even though Chromium is alive. # ============================================================================ check_flystage() { local metrics frames clients ok=1 metrics="$(curl -fsS "${FLY_METRICS_URL}/metrics" 2>/dev/null || true)" if [ -z "$metrics" ]; then ok=0 else frames="$(echo "$metrics" | awk '/^fly_frames_sent_total/ {print $2; exit}')" clients="$(echo "$metrics" | awk '/^fly_feed_clients/ {print $2; exit}')" frames="${frames:-0}" clients="${clients:-0}" local prev_file="${WD_RUN_DIR}/flystage.prev_frames" local prev="" [ -f "$prev_file" ] && prev="$(cat "$prev_file")" echo "$frames" > "$prev_file" # clients cast to an integer comparison; awk numbers may carry a # decimal point (Prometheus text format). if [ "$(printf '%.0f' "$clients")" -eq 0 ] 2>/dev/null; then ok=0 elif [ -n "$prev" ] && [ "$prev" = "$frames" ]; then ok=0 fi fi if [ "$ok" -eq 1 ]; then record_ok flystage else record_fail flystage escalate flystage flycast fi } # ============================================================================ # Check 3: flycast — frame= in its progress file advancing across two # passes. # ============================================================================ progress_frame() { local f="$1" [ -f "$f" ] || { echo ""; return; } awk -F= '/^frame=/ {v=$2} END {print v}' "$f" } check_flycast() { local frame ok=1 frame="$(progress_frame "$FLY_PROGRESS_FLYCAST")" if [ -z "$frame" ]; then ok=0 else local prev_file="${WD_RUN_DIR}/flycast.prev_frame" local prev="" [ -f "$prev_file" ] && prev="$(cat "$prev_file")" echo "$frame" > "$prev_file" [ -n "$prev" ] && [ "$prev" = "$frame" ] && ok=0 fi if [ "$ok" -eq 1 ]; then record_ok flycast else record_fail flycast escalate flycast fi } # ============================================================================ # Check 4: mediamtx — GET /v3/paths/get/live/fly shows ready:true and # bytesReceived advancing. Else restart mediamtx, then flycast. # ============================================================================ check_mediamtx() { local body ready bytes ok=1 body="$(curl -fsS "${MEDIAMTX_API}/v3/paths/get/live/fly" 2>/dev/null || true)" if [ -z "$body" ]; then ok=0 else ready="$(echo "$body" | jq -r '.ready // false' 2>/dev/null || echo false)" bytes="$(echo "$body" | jq -r '.bytesReceived // 0' 2>/dev/null || echo 0)" if [ "$ready" != "true" ]; then ok=0 else local prev_file="${WD_RUN_DIR}/mediamtx.prev_bytes" local prev="" [ -f "$prev_file" ] && prev="$(cat "$prev_file")" echo "$bytes" > "$prev_file" [ -n "$prev" ] && [ "$prev" = "$bytes" ] && ok=0 fi fi if [ "$ok" -eq 1 ]; then record_ok mediamtx else record_fail mediamtx # Explicit two-step per section 3 point 4, distinct from the # generic chain escalation: mediamtx then flycast, every time, # not gated on the 3-consecutive-failure threshold, because a # dead mediamtx always needs flycast bounced (it will be tee-ing # into a closed RTMP publisher). restart_unit mediamtx restart_unit flycast fi } # ============================================================================ # Check 5: flypush — frame= in its own progress file advancing. Only runs # when flypush is enabled (PUSH_TARGET=twitch); local mode leaves it # disabled and this check is skipped, matching "Twitch-side liveness is # checked centrally [on the metrics container], not here, so two containers do not both # poll Helix." # ============================================================================ check_flypush() { is_enabled flypush.service || return 0 local frame ok=1 frame="$(progress_frame "$FLY_PROGRESS_FLYPUSH")" if [ -z "$frame" ]; then ok=0 else local prev_file="${WD_RUN_DIR}/flypush.prev_frame" local prev="" [ -f "$prev_file" ] && prev="$(cat "$prev_file")" echo "$frame" > "$prev_file" [ -n "$prev" ] && [ "$prev" = "$frame" ] && ok=0 fi if [ "$ok" -eq 1 ]; then record_ok flypush else record_fail flypush escalate flypush fi } # ============================================================================ # Check 6: disk guard. >85% triggers immediate prune; >95% drops the # recording leg while keeping the stream up, and raises the alarm metric. # # Known gap (documented, not silently dropped): flycast.service runs the # single fixed ffmpeg command from docs/design/infra.md section 3, which # has no "no-recording" mode. At 95% this check runs fly-retention in its # most aggressive pass and sets the alarm metric; it does NOT currently # reconfigure flycast to stop writing segments, because that needs a # second flycast command variant that is out of scope for this infra pass. # See docs/runbook.md "known gaps". # ============================================================================ check_disk() { local pct pct="$(df --output=pcent "$FLY_MEDIA_DIR" 2>/dev/null | tail -n1 | tr -d '% ')" pct="${pct:-0}" if [ "$pct" -ge 95 ]; then echo 1 > "${WD_RUN_DIR}/disk_critical" log_err "disk guard: ${FLY_MEDIA_DIR} at ${pct}% (>=95%), forcing aggressive retention and alarming" [ -x "$RETENTION_BIN" ] && "$RETENTION_BIN" --aggressive || true elif [ "$pct" -ge 85 ]; then echo 0 > "${WD_RUN_DIR}/disk_critical" log_info "disk guard: ${FLY_MEDIA_DIR} at ${pct}% (>=85%), triggering retention" [ -x "$RETENTION_BIN" ] && "$RETENTION_BIN" || true else echo 0 > "${WD_RUN_DIR}/disk_critical" fi } # ============================================================================ # Check 7: process age guard. flypush uptime above 24h forces a restart # even if the 23h timer misfired — the local belt for the 48h Twitch cap. # ============================================================================ check_process_age() { is_enabled flypush.service || return 0 is_active flypush.service || return 0 local started epoch age started="$(systemctl show -p ActiveEnterTimestamp --value flypush.service 2>/dev/null)" [ -n "$started" ] || return 0 epoch="$(date -d "$started" +%s 2>/dev/null || echo 0)" [ "$epoch" -gt 0 ] || return 0 age=$(( $(now) - epoch )) if [ "$age" -ge 86400 ]; then log_err "process age guard: flypush has been up ${age}s (>=24h), forcing restart ahead of the 48h Twitch cap" restart_unit flypush # Not record_fail/escalate: this is not a failed health check, it is # a scheduled preventative restart, so it gets its own counter # (unit="flypush",reason="age" in write_textfile_metrics) rather # than feeding the consecutive-failure/escalation machinery above. inc_counter "restarts_total_age" "flypush" fi } # ============================================================================ # Check 8: encoder backend (docs/design/gpu.md section 5). Reads the # fly_encoder_backend series that bin/flycast-launch writes — it is the only # thing that knows which encoder actually opened — and compares it against # what /etc/fly/fly.env asked for. # # This check deliberately takes NO remediation. If FLY_ENCODER=nvenc and # flycast is on x264, the fallback did its job: the stream is up, at the # cost of about two cores. Restarting flycast to retry nvenc would flap the # stream every 60 s for as long as the GPU workload in the neighbouring container is holding the VRAM, # which is worse than a degraded encode. So it logs, exports a gauge, and # leaves the decision to a human with the runbook open. # # It does not duplicate fly_encoder_backend into this file's own .prom: # node_exporter refuses duplicate series across textfile collectors, and # flycast-launch's file is already scraped. # ============================================================================ encoder_live_backend() { [ -f "$FLY_ENCODER_METRIC" ] || return 0 awk '/^fly_encoder_backend\{/ && $NF == 1 { if (match($0, /backend="[^"]+"/)) { print substr($0, RSTART + 9, RLENGTH - 10) exit } }' "$FLY_ENCODER_METRIC" 2>/dev/null || true } encoder_configured_backend() { [ -f "$FLY_ENV_FILE" ] || return 0 awk -F= '/^FLY_ENCODER=/ { print $2; exit }' "$FLY_ENV_FILE" 2>/dev/null | tr -d ' \r' || true } check_encoder() { local live want live="$(encoder_live_backend)" want="$(encoder_configured_backend)" if [ -z "$live" ]; then # No metric yet (flycast has never started, or the textfile dir is # not writable). Unknown, not broken. echo -1 > "${WD_RUN_DIR}/encoder_degraded" return 0 fi if [ -z "$want" ] || [ "$live" = "$want" ]; then echo 0 > "${WD_RUN_DIR}/encoder_degraded" return 0 fi echo 1 > "${WD_RUN_DIR}/encoder_degraded" log_err "encoder degraded: flycast is running '${live}' but /etc/fly/fly.env asks for '${want}' — the nvenc fallback fired." \ "Not restarting (that would flap the stream while the neighbouring GPU container holds the VRAM). See docs/runbook.md 'GPU driver version lockstep'" \ "and check: nvidia-smi --query-gpu=memory.used,utilization.encoder --format=csv" } # ============================================================================ # Check 9: capture freeze (infra/docs/capture-freeze.md). # # The failure this catches is invisible to every check above. flycast's ffmpeg # can come up with its x11grab leg permanently starved: about one new picture # per second and 29 repeats, while the audio is perfect, `frame=` in the # progress file advances at exactly 30 fps (so check 3 is happy), dup/drop # stay at 0, and the page itself is fine (so check 2 is happy). Measured on # The release container on 2026-09-16: 3 h 50 min of frozen broadcast from the 06:35:52 UTC # container boot until a manual `systemctl restart flycast` at 10:26 UTC. # The ordering fix (flycast.service's After=flystage.service plus # bin/wait-for-stage) closes the window that was reproduced on the dev container; this # check is the belt, because the mechanism inside ffmpeg is NOT established # and nothing here proves the ordering gate closes every path into it. # # The instrument matters more than the threshold, and getting it wrong is how # this bug survived a morning: the count MUST come from the ENCODER OUTPUT # (the newest segment, which is the same bytes MediaMTX and Twitch get), never # from an independent x11grab of :99 — the display was updating normally the # whole time. # # Cost control: every 5 minutes (not every 60 s pass), 3 s of decode at # 320x240-ish cost, `nice -n 10`, and pinned with taskset to flycast's own # AllowedCPUs so the probe cannot touch flysim's cores. The deploy lesson # (docs/stream-mvp-plan.md, 2026-09-16) is that unpinned in-container work is # what pushes sim lag up. # ============================================================================ # The cpus flycast itself is confined to (05-deploy.sh section 3b writes this # drop-in from CPUSET/RAYON_THREADS/ENCODER_CORES). Empty when no partition is # configured, in which case the probe runs unpinned — same no-op rule the # deploy uses. encoder_cpus() { [ -f "$FLYCAST_CPUSET_DROPIN" ] || return 0 awk -F= '/^AllowedCPUs=/ { gsub(/[ \t\r]/, "", $2); print $2; exit }' "$FLYCAST_CPUSET_DROPIN" 2>/dev/null || true } newest_segment() { [ -d "$FLY_REC_DIR" ] || return 0 # -printf '%T@ %p' then sort: no `ls` parsing, and it tolerates a # directory with thousands of segments. find "$FLY_REC_DIR" -maxdepth 1 -type f -name '*.ts' -printf '%T@ %p\n' 2>/dev/null \ | sort -nr | head -n1 | cut -d' ' -f2- || true } # freeze_probe SEGMENT — echoes "IDENTICAL TOTAL" for the last # WD_FREEZE_SECONDS of SEGMENT. IDENTICAL counts `Parsed_blackframe` report # lines (each carries `] frame:`); with tblend's difference output, a frame # that reads as black means it was identical to the one before it. TOTAL comes # from ffmpeg's own last stats line, so a short decode can be told apart from # a still picture. freeze_probe() { local seg="$1" out identical total pin=() nice_pre=() cpus ffmpeg_bin="$FFMPEG" if [ ! -x "$ffmpeg_bin" ]; then ffmpeg_bin="$(command -v ffmpeg 2>/dev/null || true)" [ -n "$ffmpeg_bin" ] || return 1 fi cpus="$(encoder_cpus)" [ -n "$cpus" ] && command -v taskset >/dev/null 2>&1 && pin=(taskset -c "$cpus") command -v nice >/dev/null 2>&1 && nice_pre=(nice -n 10) # A probe that hangs must not hold the watchdog pass open; a missing # `timeout` just means no guard. local guard=() command -v timeout >/dev/null 2>&1 && guard=(timeout 60) out="$("${pin[@]}" "${nice_pre[@]}" "${guard[@]}" "$ffmpeg_bin" -hide_banner -nostdin \ -sseof "-$(( WD_FREEZE_SECONDS + 1 ))" -i "$seg" -t "$WD_FREEZE_SECONDS" \ -vf "tblend=all_mode=difference,blackframe=amount=99.5:threshold=8" \ -an -f null - 2>&1)" || return 1 identical="$(printf '%s\n' "$out" | grep -c 'Parsed_blackframe.*] frame:' || true)" total="$(printf '%s\n' "$out" | grep -oE 'frame= *[0-9]+' | tail -n1 | grep -oE '[0-9]+' || true)" echo "${identical:-0} ${total:-0}" } check_capture_freeze() { local last_file="${WD_RUN_DIR}/freeze.last_probe" last=0 n n="$(now)" [ -f "$last_file" ] && last="$(cat "$last_file" 2>/dev/null || echo 0)" [ $(( n - ${last:-0} )) -ge "$WD_FREEZE_INTERVAL" ] || return 0 # Only meaningful while flycast is actually running and writing: a stopped # encoder leaves a static tail on the last segment, and restarting flycast # because the operator stopped it would be its own incident. is_active flycast.service || return 0 local seg seg="$(newest_segment)" if [ -z "$seg" ]; then return 0 fi local age age=$(( n - $(stat -c %Y "$seg" 2>/dev/null || echo 0) )) if [ "$age" -gt "$WD_FREEZE_SEGMENT_MAX_AGE" ]; then log_info "capture freeze probe: newest segment ${seg} is ${age}s old, not being written — skipping" return 0 fi echo "$n" > "$last_file" local result identical total if ! result="$(freeze_probe "$seg")"; then log_err "capture freeze probe: ffmpeg probe of ${seg} failed — check 9 is blind this pass (is ffmpeg installed?)" return 0 fi read -r identical total <<< "$result" echo "$identical" > "${WD_RUN_DIR}/freeze.identical" # A short decode under-counts, which can only ever HIDE a freeze, never # invent one — the safe direction for something that restarts a live # broadcast. Say so and do not compare. if [ "${total:-0}" -lt "$WD_FREEZE_MIN_FRAMES" ]; then log_info "capture freeze probe: only ${total} frames decoded from ${seg} (identical ${identical}), below ${WD_FREEZE_MIN_FRAMES} — inconclusive, not counted" return 0 fi local run_file="${WD_RUN_DIR}/freeze.consecutive" run=0 [ -f "$run_file" ] && run="$(cat "$run_file" 2>/dev/null || echo 0)" run="${run:-0}" if [ "$identical" -lt "$WD_FREEZE_IDENTICAL" ]; then [ "$run" -gt 0 ] && log_info "capture freeze probe: ${identical}/${total} identical frames in ${seg} — healthy, clearing the run of ${run}" echo 0 > "$run_file" return 0 fi run=$(( run + 1 )) echo "$run" > "$run_file" log_err "capture freeze probe: ${identical} of ${total} frames in ${seg} are identical to the one before them (threshold ${WD_FREEZE_IDENTICAL}) — this is the encoder output, so the broadcast is frozen. Consecutive bad probes: ${run}." # One bad probe is not enough: a legitimately still page (a long caption # hold with the fly off) could read high once. [ "$run" -ge 2 ] || return 0 local restart_file="${WD_RUN_DIR}/freeze.last_restart" last_restart=0 [ -f "$restart_file" ] && last_restart="$(cat "$restart_file" 2>/dev/null || echo 0)" if [ $(( n - ${last_restart:-0} )) -lt "$WD_FREEZE_COOLDOWN" ]; then log_err "capture freeze: still frozen, but flycast was already restarted for this $(( (n - last_restart) / 60 ))m ago (<$(( WD_FREEZE_COOLDOWN / 60 ))m gate) — alarming only. If it did not clear, the ordering gate did not help: read infra/docs/capture-freeze.md and check whether a pulse client attached during ffmpeg's first seconds." return 0 fi log_err "capture freeze: ${run} consecutive probes at or above ${WD_FREEZE_IDENTICAL} identical frames — restarting flycast ONCE (next restart for this reason no sooner than $(( WD_FREEZE_COOLDOWN / 60 ))m from now)" echo "$n" > "$restart_file" echo 0 > "$run_file" inc_counter "capture_freeze_restarts" restart_unit flycast } # ============================================================================ # Check 10: loop suspected (infra/docs/macros-traps.md). # # The failure this reports is the one nothing else in this file can see. In # macros mode the fly can spend hours pressing a short cycle of macros that # each SUCCEED — `GO NPC, GO OUT, NEXT, GO FRONTIER` every ~3 brain seconds # was the live Viridian loop of 2026-09-17 — while standing on the same two # tiles. Every check above stays green throughout: the sim advances (check 1), # the page draws (check 2), the encoder encodes (checks 3, 4, 9). The # stuck-o-meter climbs and nothing acts on it, because the ratchet spends its # rollback budget and then refuses by contract (macros-traps.md row 22: "it is # why the macro layer must break its own loops, and why `trap_hunt` exists"). # # THIS CHECK NEVER ACTS. It does not restart flysim, it does not press # anything, it does not touch the game, and it does not feed the # record_fail/escalate machinery. A loop is a behavioural judgement about a # live brain, and the remedy is a change to what a macro's target choice # considers — never a bounce, which would restore a checkpoint straight back # into the same loop with the stream interrupted for nothing. So it measures, # exports gauges, logs one line, and writes ${WD_LOOP_REPORT} for a human or a # review agent to pick up (infra/docs/runbook.md "loop suspected"). Deciding # is somebody else's job. # # The instrument is `examples/trap_hunt.rs`'s, narrowed to one window: the # `macro` start events of the last WD_LOOP_WINDOW_MS BRAIN milliseconds (brain # time, not wall time — a box running under 1.0x realtime would otherwise get # a shorter window than the thresholds were measured over), the distinct macro # names in it, and the shortest block up to WD_LOOP_MAX_PERIOD that repeats at # the end of the sequence. The sequence is the fly's DECISIONS: a `start`, or a # `refused` (a bound button that was pressed and did nothing, with no start # beside it). Counting the `done` beside a start would double every sequence # and halve every period. They are the same names trap_hunt prints, so its # numbers and these are comparable. # # Row 57 (macros-traps.md): counting starts alone was blind to a pad whose # one button refused. `GO ROUTE refused` ~740 times per 10 brain minutes for # two hours read as 1 start, 1 distinct name, nothing flagged. So the window # also counts every OUTCOME (done/blocked/timeout/refused), and two rules read # them, both behind the same "no new ground" gate as the others: # stalled — WD_LOOP_MIN_EVENTS+ decisions and WD_LOOP_STALL_PCT% of # them ended refused, blocked or timed out; # zero-progress — decisions in the window, not one `done` among them, on # this probe AND the previous one (two probes, so a single # unlucky window never flags). # # The tile rule is what separates a loop from a legitimately repeating # explorer (macros-traps.md: `GO FRONTIER` x19 over 93 tiles is a walk longer # than the frame cap, not a trap), and the watchdog has no tile counter. The # coarser thing /status.json does publish is the exploration count — and NOTE # ITS NAME: the field is `game.uniqueLocations` (docs/feed-protocol.md), not # `places`, which is only what the metric here is called. No growth in it # across two probes AND a short repeating sequence is the flag; either alone # is not, which is deliberate: the false positive of a watchdog crying loop at # a fly that is simply walking a long way is how this report would get # ignored. # # Cost: one `tail`, one jq per pass at most every 5 minutes, and no decode. # ============================================================================ # loop_status_fields — one fetch of /status.json, one parse. Echoes # uniqueLocations, map, milestone rank/label/next/sinceSeconds, macroMode and # padEmptyMs, in that order, separated by US (\037); an empty string when the # read-only listener does not answer or the body is not the JSON it should be. # # padEmptyMs is section 13.1's report-only reading of "the macro buttons # disappeared and everything hangs" (docs/design/macros.md). It is EMPTY, not # zero, when the service predates the field — which is the same "unknown, not # broken" convention the -1 gauges use, and why nothing here defaults it to 0. # # US and not a tab, here and in loop_analyze below, because `read` collapses # runs of IFS WHITESPACE — and a tab is IFS whitespace. With tabs, a null # `game.map` or a window with nothing repeating (an empty field either way) # silently shifts every field after it by one, which is how the sequence in # the journal line first came out as the twelve-name context tail. loop_status_fields() { local body body="$(curl -fsS --max-time 5 "$FLY_STATUS_URL" 2>/dev/null || true)" [ -n "$body" ] || return 0 printf '%s' "$body" | jq -r ' [ (.game.uniqueLocations // ""), (.game.map // ""), (.milestone.rank // ""), (.milestone.label // ""), (.milestone.next // ""), (.milestone.sinceSeconds // ""), (.game.macroMode // "raw"), (.game.padEmptyMs // "") ] | map(tostring) | join("\u001f")' 2>/dev/null || true } # loop_macro_stream — "\t