#!/usr/bin/env bash # The idle timeout is a script that kills a match, so the interesting cases are # the ones where it must not: a manifest that asks for no timeout, a game that # keeps showing signs of play, and a game that is over. Each is covered here, # along with the ones where it must - a lobby nobody joins and a game that # stalls mid-round. # # How it shuts down matters as much as whether. idle.sh signals the supervisor # rather than the host, because the entrypoint's TERM trap finalizes - uploading # the result, the screenshots and the diagnostics - and a hard kill would throw # all of that away silently. The stub supervisor below has that trap shape and # nothing else, so a marker file written from its trap is the proof that what # arrived was a signal a trap can catch. set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT fail=0 check() { # if "${@:2}"; then echo "ok $1"; else echo "FAIL $1"; fail=1; fi } # The image collects container/ under ARENA_HOME; idle.sh sources lib/common.sh # from there. mkdir -p "$TMP/home" "$TMP/run/state" ln -s "$ROOT/container" "$TMP/home/container" STATE="$TMP/run/state" MARK="$TMP/finalized" # Each case starts from a container that has just come up. reset_state() { rm -f "$STATE/host.status" "$STATE/host.activity" "$STATE/game-over"; } # --- a stand-in for entrypoint.sh --------------------------------------------- # The real one wants a JRE, Suramadu and the MegaMek tree, none of which exist # outside the image. What it contributes to this test is one line - `trap # shutdown INT TERM`, where shutdown finalizes - and that is what this is. cat > "$TMP/supervisor.sh" <<'SUPERVISOR' finalize() { echo finalized > "$MARK"; } trap 'finalize; exit 143' TERM while true; do sleep 0.1; done SUPERVISOR SUP="" start_supervisor() { rm -f "$MARK" MARK="$MARK" bash "$TMP/supervisor.sh" & SUP=$! } stop_supervisor() { [ -n "$SUP" ] && kill -KILL "$SUP" 2>/dev/null wait "$SUP" 2>/dev/null SUP="" } # The trap runs after idle.sh has already exited, so both of these wait. finalized() { for _ in $(seq 30); do [ -f "$MARK" ] && return 0; sleep 0.1; done return 1 } supervisor_gone() { for _ in $(seq 30); do kill -0 "$SUP" 2>/dev/null || return 0; sleep 0.1; done return 1 } untouched() { # nothing was signalled: still running, never finalized sleep 0.4 kill -0 "$SUP" 2>/dev/null && [ ! -f "$MARK" ] } manifest() { # [callback-url] jq -n --argjson limits "$1" --arg cb "${2:-}" \ '{matchId: "test-match"} + (if $limits == null then {} else {limits: $limits} end) + (if $cb == "" then {} else {callback: {url: $cb}} end)' \ > "$TMP/run/manifest.json" } # 1s rather than the manifest's 3600, and a fast poll, so the whole file runs in # a couple of seconds. run_idle() { ARENA_HOME="$TMP/home" ARENA_RUN="$TMP/run" \ ARENA_SUPERVISOR_PID="$SUP" ARENA_IDLE_POLL=0.2 \ bash "$ROOT/container/watch/idle.sh" 2>&1 } # --- nobody joins ------------------------------------------------------------- # host.status stays at what MatchHost writes when it is waiting for its humans, # and host.activity stays at the one line the host wrote coming up. reset_state echo 'listening' > "$STATE/host.status" echo "$(date +%s) round 0 LOUNGE turn -1 joined []" > "$STATE/host.activity" manifest '{"idleTimeoutSeconds": 1}' start_supervisor out="$(run_idle)"; status=$? check "an unjoined lobby times out" grep -q 'nobody joined within 1s' <<<"$out" check "and idle.sh itself exits clean" test "$status" -eq 0 check "and the shutdown finalizes" finalized check "and the supervisor is gone" supervisor_gone stop_supervisor # --- the manifest asks for no timeout ----------------------------------------- # Two spellings of "no timeout", because the difference between them is a jq # default and it would be easy to honour only one. for limits in 'null' '{"idleTimeoutSeconds": 0}' '{"lingerSeconds": 300}'; do manifest "$limits" start_supervisor out="$(run_idle)"; status=$? check "no timeout for limits=$limits" grep -q 'no idle timeout' <<<"$out" check " exits clean" test "$status" -eq 0 check " and signals nothing" untouched stop_supervisor done # A value that is not a number is a manifest bug, and shooting a match down on a # guess is worse than not having the timeout. manifest '{"idleTimeoutSeconds": "soon"}' start_supervisor out="$(run_idle)" check "a non-numeric value stands down" grep -q 'not a whole number' <<<"$out" check " and signals nothing" untouched stop_supervisor # --- the game stalled --------------------------------------------------------- # Joined, started, and then nothing: host.activity stops moving mid-round. The # old watcher disarmed for good on the first join, and this bill is what that # reading missed. reset_state echo 'round 3 MOVEMENT' > "$STATE/host.status" echo "$(date +%s) round 3 MOVEMENT turn 4 joined [alice]" > "$STATE/host.activity" manifest '{"idleTimeoutSeconds": 1}' start_supervisor out="$(run_idle)"; status=$? check "a stalled game times out" grep -q 'no sign of play' <<<"$out" check " naming where it stalled" grep -q 'round 3 MOVEMENT' <<<"$out" check " exits clean" test "$status" -eq 0 check " and the shutdown finalizes" finalized stop_supervisor # A host that never wrote host.activity - dead before its first tick, or an # image mixing an old host with this watcher - is held to the same ceiling, # counted from arming. The other reading, exempt forever, is the 83-minute # container again. reset_state echo 'lobby-ready' > "$STATE/host.status" manifest '{"idleTimeoutSeconds": 1}' start_supervisor out="$(run_idle)" check "no activity file still times out" grep -q 'no sign of play' <<<"$out" check " and finalizes" finalized stop_supervisor # --- the game is being played ------------------------------------------------- # host.activity refreshed faster than the timeout holds the watcher off for as # long as the signs keep coming; game-over then stands it down. The refresher # here is MatchHost on a good day. 2s rather than 1: the stamps are whole # seconds, so a fresh write can read as a full second stale at a 1s ceiling - # real ceilings are hours, where the truncation is noise. reset_state echo 'round 3 MOVEMENT' > "$STATE/host.status" manifest '{"idleTimeoutSeconds": 2}' start_supervisor ( for turn in $(seq 8); do echo "$(date +%s) round 3 MOVEMENT turn $turn joined [alice]" > "$STATE/host.activity" sleep 0.25 done : > "$STATE/game-over" ) & REFRESHER=$! out="$(run_idle)"; status=$? wait "$REFRESHER" check "a played game never fires" grep -q 'idle timeout disarmed' <<<"$out" check " exits clean" test "$status" -eq 0 check " and signals nothing" untouched stop_supervisor # --- the game is over --------------------------------------------------------- # Both spellings the host leaves behind: the status line and the marker file. # The host is deliberately lingering then, and must be left alone. manifest '{"idleTimeoutSeconds": 1}' reset_state echo 'game-over' > "$STATE/host.status" start_supervisor out="$(run_idle)"; status=$? check "disarmed by host.status 'game-over'" grep -q 'idle timeout disarmed' <<<"$out" check " exits clean" test "$status" -eq 0 check " and signals nothing" untouched stop_supervisor reset_state echo 'listening' > "$STATE/host.status" : > "$STATE/game-over" start_supervisor out="$(run_idle)" check "disarmed by the game-over marker" grep -q 'idle timeout disarmed' <<<"$out" check " and signals nothing" untouched stop_supervisor # --- the callback ------------------------------------------------------------- # Port 1 refuses at once, so `emit` fails and names the event it was sending in # the warning. That is the assertion: headquarters has to be able to tell an # idle timeout from a crash, and it can only do that by the event name. reset_state echo 'listening' > "$STATE/host.status" manifest '{"idleTimeoutSeconds": 1}' 'http://127.0.0.1:1/events' start_supervisor out="$(run_idle)" check "the event is idle-timeout" grep -q "callback 'idle-timeout' failed" <<<"$out" check "an unreachable callback still shuts down" finalized stop_supervisor # --- wiring ------------------------------------------------------------------- # The half of this that cannot run outside the image: that the entrypoint starts # the watcher, tells it which process to signal, and traps that signal into the # path that finalizes. ENTRY="$ROOT/container/entrypoint.sh" check "the entrypoint starts the watcher" grep -q 'watch/idle.sh' "$ENTRY" check "and exports its own pid" grep -q 'ARENA_SUPERVISOR_PID=\$\$' "$ENTRY" check "and traps the signal it will send" grep -qE '^trap shutdown( .*)? TERM' "$ENTRY" check "and that trap finalizes" bash -c \ "sed -n '/^shutdown()/,/^}/p' \"\$1\" | grep -q finalize_once" _ "$ENTRY" exit "$fail"