#!/usr/bin/env bash # PID 1 for a match container. # # init/*.sh in lexical order - manifest, identity, assets, config # suramadu Suramadu, serving the real MegaMek Swing client at :8080 # host arena.MatchHost - the MegaMek server, Princess, one game # watcher arena.MatchWatcher - observer client spooling turn reports # watch/ uploads what the watcher spools, while the match is still running, # and shuts an unjoined lobby down before it bills a full lifetime # exit/ runs once the host is finished, whether by victory or by signal # # The host is the process whose exit decides the container's. Suramadu and # the watcher are support: if either dies the match is degraded, not over, and # taking the container down would cost players a game in progress. set -euo pipefail # shellcheck disable=SC2034 # read by common.sh's log helpers ARENA_LOG_TAG=entrypoint # shellcheck source=lib/common.sh . "${ARENA_HOME:-/opt/arena}/container/lib/common.sh" # common.sh defaulted ARENA_HOME; export it so child scripts see the same root. export ARENA_HOME MM_HOME="$ARENA_HOME/megamek" JAVA="$ARENA_HOME/jre/bin/java" WS_HOME="$ARENA_HOME/suramadu" # java.util.prefs writes under user.home, which the JVM reads from the passwd # entry - not from $HOME, which it ignores on Linux. The image therefore gives # the arena user a home on the tmpfs (see the Dockerfile); this creates it. # # Without it every JVM logs "Couldn't create user preferences directory" twice # at startup, including the Swing client, which Suramadu spawns and which # would not see a -D we put on our own command lines. export HOME="$ARENA_RUN/home" mkdir -p "$HOME" WS_PID="" HOST_PID="" WATCHER_PID="" RESULTS_PID="" IDLE_PID="" FINALIZED=0 # --- phase timing ------------------------------------------------------------ # Milliseconds since this script started, logged at each boot phase and sent # with the readiness callback. # # This is the only place the boot phases can be told apart. ECS reports when it # pulled the image and when it started the container, and headquarters sees the # callbacks - but nothing between those two says where the seconds went, which # is why "the pull is ~16s" is the only start-time number we have ever had. T0_MS="$(date +%s%3N)" elapsed_ms() { echo $(( $(date +%s%3N) - T0_MS )); } phase() { log "phase=$1 at=+$(elapsed_ms)ms"; } # There is no ss or netstat in a slim image, so readiness is read from # /proc/net/tcp, whose columns are: sl local_address rem_address st ... # # Both the column and the state matter. Grepping the whole line also matches the # *remote* address, so an outbound connection to the same port would look like a # server; and without st=0A a socket in any state at all counts as listening. port_listening() { local hex hex="$(printf '%04X' "$1")" awk -v want=":$hex" '$2 ~ (want "$") && $4 == "0A" { found = 1 } END { exit !found }' /proc/net/tcp /proc/net/tcp6 2>/dev/null } # Both JVMs are running by the time either is waited on, so a startup failure # has to take the other down with it rather than leave it orphaned under a PID 1 # that is about to exit. # # The callback is what stops the waiting screen sitting through the whole # provisioning timeout: headquarters otherwise learns nothing until it notices # the task has stopped, which is several polls later. startup_failed() { emit error "$1" for pid in "$HOST_PID" "$WS_PID"; do [ -n "$pid" ] && kill -TERM "$pid" 2>/dev/null || true done die "$1" } # await_port [logfile] # Polls twice a second. A process that dies while being waited on fails # immediately rather than burning the whole timeout. await_port() { local what="$1" port="$2" pid="$3" tries="$4" logfile="${5:-}" local _ for _ in $(seq "$tries"); do if ! kill -0 "$pid" 2>/dev/null; then [ -n "$logfile" ] && { tail -40 "$logfile" >&2 || true; } startup_failed "$what exited during startup" fi port_listening "$port" && return 0 sleep 0.5 done # Falling through to "it is listening" would report a server that never came # up as ready, and leave the failure to show up as an unexplained connection # refused in someone's browser. [ -n "$logfile" ] && { tail -40 "$logfile" >&2 || true; } startup_failed "$what did not open :$port within $((tries / 2))s" } # Fargate sends SIGTERM and then waits. That window is the only chance to upload # a result, so finalize runs on the signal path too, not just on clean victory. finalize_once() { [ "$FINALIZED" = "1" ] && return 0 FINALIZED=1 log "finalizing ..." "$ARENA_HOME/container/exit/finalize.sh" || warn "finalize reported errors" } # Whether this match is being profiled. Off unless the task sets ARENA_PROFILE # to something other than 0; see the agent block under "match host". profiling() { [ -n "${ARENA_PROFILE:-}" ] && [ "$ARENA_PROFILE" != "0" ]; } # async-profiler writes its output when the JVM exits, and finalize collects it, # so on a profiled run the JVMs have to be stopped before finalize rather than # after. Suramadu is signalled too: it owns the client JVM and terminates it # with SIGTERM, which is what lets that JVM's agent flush as well. # # Only for profiled runs. An ordinary match keeps finalize first, because a # SIGTERM window is the one chance to upload a result and no diagnostic is worth # spending it on. flush_profiles() { log "stopping the JVMs so their profiles flush" for pid in "$HOST_PID" "$WS_PID"; do [ -n "$pid" ] && kill -TERM "$pid" 2>/dev/null || true done for _ in $(seq 40); do kill -0 "$HOST_PID" 2>/dev/null || break sleep 0.25 done # The client JVM is a grandchild, so there is nothing to wait on; give it the # same 10s ceiling and stop as soon as the file lands. for _ in $(seq 40); do [ -s "$ARENA_STATE/profile-client.collapsed" ] && break sleep 0.25 done } shutdown() { log "signal received; shutting down" if profiling; then flush_profiles; fi finalize_once for pid in "$IDLE_PID" "$RESULTS_PID" "$WATCHER_PID" "$HOST_PID" "$WS_PID"; do [ -n "$pid" ] && kill -TERM "$pid" 2>/dev/null || true done # Give the JVMs a moment to close their sockets before the container dies. for _ in $(seq 40); do kill -0 "$HOST_PID" 2>/dev/null || break sleep 0.25 done exit 143 } trap shutdown INT TERM # --- init ------------------------------------------------------------------- for step in "$ARENA_HOME"/container/init/*.sh; do log "running $(basename "$step")" ARENA_HOME="$ARENA_HOME" "$step" done phase init-done # shellcheck source=/dev/null . "$ARENA_RUN/players.env" # shellcheck source=/dev/null . "$ARENA_RUN/scenario.env" LINGER="$(mq '.limits.lingerSeconds' 300)" AUTO_READY="$(mq '.limits.autoReadySeconds' 0)" OBSERVER_ENABLED="$(mq '.observer.enabled' true)" # --- Suramadu --------------------------------------------------------------- # Launched first because it is the slower of the two to open its port, but only # launched here - the wait for it moved below the host, so the two JVMs warm up # at the same time instead of end to end. Neither needs the other to start: the # host is a MegaMek server, and Suramadu only dials it once a browser arrives. # # The flag list is the one Suramadu's toolkit requires to reach into # java.desktop internals; it is not tunable. log "starting Suramadu on :$PORT_WS" cd "$WS_HOME" env -u DISPLAY -u XAUTHORITY "$JAVA" -server -Xmx"${ARENA_WS_HEAP:-1G}" -Xms256m \ --add-modules=java.desktop \ --add-exports=java.desktop/sun.awt=ALL-UNNAMED \ --add-exports=java.desktop/sun.awt.dnd=ALL-UNNAMED \ --add-exports=java.desktop/sun.awt.datatransfer=ALL-UNNAMED \ --add-exports=java.desktop/sun.awt.image=ALL-UNNAMED \ --add-exports=java.desktop/sun.java2d=ALL-UNNAMED \ --add-exports=java.desktop/sun.java2d.pipe=ALL-UNNAMED \ --add-exports=java.desktop/sun.java2d.loops=ALL-UNNAMED \ --add-exports=java.desktop/sun.font=ALL-UNNAMED \ --add-exports=java.desktop/sun.print=ALL-UNNAMED \ --add-exports=java.desktop/java.awt.peer=ALL-UNNAMED \ --add-exports=java.desktop/java.awt.dnd=ALL-UNNAMED \ --add-exports=java.base/sun.nio.cs=ALL-UNNAMED \ --add-opens=java.desktop/sun.awt.image=ALL-UNNAMED \ -Dwebswing.websocketMessageSizeLimit=1024000 \ -Dlog4j2.configurationFile="$ARENA_HOME/config/log4j2-server.xml" \ -jar "$WS_HOME/$ARENA_SURAMADU_WAR" \ -h 0.0.0.0 \ -j "$ARENA_RUN/suramadu/jetty.properties" \ -c "$ARENA_RUN/suramadu/suramadu.config" \ -pf "$WS_HOME/suramadu.properties" \ > "$ARENA_RUN/suramadu.log" 2>&1 & WS_PID=$! # Mirror Suramadu's log onto stdout so the task's log stream carries every # process - session lifecycle (and disconnect reasons) live only here. The # file stays authoritative: startup failure still tails it directly. tail -n +1 -F "$ARENA_RUN/suramadu.log" 2>/dev/null | sed -u 's/^/[suramadu] /' & phase suramadu-launched # --- match host ------------------------------------------------------------- CP="$ARENA_HOME/arena.jar:$ARENA_HOME/MegaMek-patched.jar" for jar in "$MM_HOME"/lib/*.jar; do CP="$CP:$jar"; done # Written by 30-assets.sh, and absent when nothing was staged - a manifest with # no camo, or every camo having failed its digest. The host takes no flag then # and every slot keeps the colours MegaMek assigns it. CAMO_ARGS=() if [ -s "$ARENA_RUN/camo/index.tsv" ]; then CAMO_ARGS=(--camo-index "$ARENA_RUN/camo/index.tsv") fi # Written by 20-identity.sh, and empty when no slot in the manifest carries a # team. The host takes no flag then and the scenario's own `team_` lines stand. TEAM_ARGS=() if [ -s "$ARENA_RUN/teams.tsv" ]; then TEAM_ARGS=(--teams "$ARENA_RUN/teams.tsv") fi # --- the profiling agent, if this match is being profiled --------------------- # Empty unless ARENA_PROFILE is set to something other than 0, so an unprofiled # match runs the command line it ran before this existed. The client JVM gets # the same treatment through vmArgs; see container/init/40-render-config.sh. # # ctimer, not cpu: `cpu` means perf_events, and Docker's default seccomp profile # blocks perf_event_open while Fargate gives no way to relax it. 4.x does fall # back to ctimer on its own, but a profiler that silently degrades is not what # you want to discover after the match. ctimer is timer_create(CLOCK_THREAD_ # CPUTIME_ID), needs no privileges and no file descriptor per thread; the price # is no kernel stacks, which we could not have had here anyway. # # CPU rather than wall clock, because the question this JVM answers is whether # Princess's pathfinding takes cores away from rendering on a 2-vCPU task. Wall # clock here would mostly report threads sitting in accept(). PROFILE_ARGS=() if profiling; then AP_SO="$ARENA_HOME/async-profiler/lib/libasyncProfiler.so" AP_EVENT="${ARENA_PROFILE_HOST_EVENT:-ctimer}" AP_INTERVAL="${ARENA_PROFILE_INTERVAL:-10ms}" AP_OUT="$ARENA_STATE/profile-host.collapsed" [ -r "$AP_SO" ] || die "ARENA_PROFILE is set but $AP_SO is missing" PROFILE_ARGS=( -XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints "-agentpath:$AP_SO=start,event=$AP_EVENT,interval=$AP_INTERVAL,memlimit=128m,collapsed,file=$AP_OUT" ) log "profiling the match host: event=$AP_EVENT interval=$AP_INTERVAL -> $AP_OUT" fi # cwd must be MegaMek's install dir: it resolves data/ relative to it. cd "$MM_HOME" log "starting match host on :$PORT_MM (humans: $ARENA_HUMAN_SLOTS)" # A ctimer profile of this JVM over a whole 4v4 put 52.9% of its samples in the # JIT compiler and 10.6% in class loading - it spends more CPU compiling # MegaMek than doing anything else, on a task that has two vCPUs for Princess # and the frame path as well. ARENA_HOST_JIT_ARGS is how that gets experimented # with; empty is stock, and `-XX:TieredStopAtLevel=1` is the interesting end. read -r -a HOST_JIT_ARGS <<< "${ARENA_HOST_JIT_ARGS:-}" "$JAVA" -Xmx"${ARENA_HOST_HEAP:-1g}" \ ${HOST_JIT_ARGS[@]+"${HOST_JIT_ARGS[@]}"} \ "${PROFILE_ARGS[@]}" \ -Dlog4j2.configurationFile="$ARENA_HOME/config/log4j2-quiet.xml" \ -Dsentry.dsn= \ --add-opens java.base/java.util=ALL-UNNAMED \ --add-opens java.base/java.util.concurrent=ALL-UNNAMED \ -cp "$CP" \ arena.MatchHost \ --scenario "$ARENA_SCENARIO" \ --scenario-source "${ARENA_SCENARIO_SOURCE:-}" \ --scenario-library "${ARENA_SCENARIO_LIBRARY:-}" \ --port "$PORT_MM" \ --humans "$ARENA_HUMAN_SLOTS" \ --names "$ARENA_RUN/names.tsv" \ --state-dir "$ARENA_STATE" \ --linger "$LINGER" \ --auto-ready "$AUTO_READY" \ "${CAMO_ARGS[@]}" \ "${TEAM_ARGS[@]}" & HOST_PID=$! phase host-launched emit running "host started" # --- observer + uploader ---------------------------------------------------- # Beside the host, not after readiness. This is a full MegaMek client JVM and # its cold start is not free on two cores. Started after `ready` it competed # with the thing a player is actually watching: headquarters redirects on that # callback, Suramadu spawns the client JVM, and the board paints for the first # time - all while this was still loading classes. Started here it overlaps the # host's own boot, which is the long pole anyway, and is settled before anyone # is told the match exists. # # It gates nothing either way: it joins as an ordinary client and the host flags # it observer, and `allHumansPresent` in MatchHost counts --humans slots, which # never include it. # # Its dumps are not uploaded. They were, to `derived/turns/`, for an AppView # that would show a match in progress - and nothing has ever read one. What # they stood in for is now kept properly: `raw/game_actions_N.tsv` is the same # match in a thirtieth of the bytes and structured, and `raw/gamelog.html` is # MegaMek's own account of it. The spool is still written, because a local run # keeps it and it is the readable form when something needs debugging by eye, # and it dies with the container like anything else nobody collects. # # The client itself is not optional. Until every human seat is filled MatchHost # leaves it unflagged, so an all-human match has one not-done player holding # the game shut; without it the game starts empty. if [ "$OBSERVER_ENABLED" != "false" ]; then log "starting observer" "$JAVA" -Xmx"${ARENA_WATCHER_HEAP:-512m}" \ -Dlog4j2.configurationFile="$ARENA_HOME/config/log4j2-quiet.xml" \ -Dsentry.dsn= \ --add-opens java.base/java.util=ALL-UNNAMED \ --add-opens java.base/java.util.concurrent=ALL-UNNAMED \ -cp "$CP" \ arena.MatchWatcher --host 127.0.0.1 --port "$PORT_MM" --spool "$ARENA_SPOOL" & WATCHER_PID=$! else log "observer disabled by manifest" fi # --- the result, sent when it exists rather than when we stop ----------------- # Outside the observer block on purpose: a match with no observer still has a # result and end-of-game images, and those belong to its players either way. "$ARENA_HOME/container/watch/results.sh" & RESULTS_PID=$! # --- readiness -------------------------------------------------------------- # Both ports, not just Suramadu's. headquarters called a match ready as soon as # the web server answered, which - with the host started after it - was always # before the host was listening, so a player could arrive at a client that had # nothing to connect to. Waiting for both is what lets the `ready` callback mean # "a browser sent here now can play". await_port Suramadu "$PORT_WS" "$WS_PID" 120 "$ARENA_RUN/suramadu.log" phase suramadu-ready WS_READY_MS="$(elapsed_ms)" await_port "match host" "$PORT_MM" "$HOST_PID" 120 phase host-ready emit ready "suramadu=${WS_READY_MS}ms host=$(elapsed_ms)ms" log "ready: Suramadu on :$PORT_WS, match host on :$PORT_MM" # --- idle timeout ----------------------------------------------------------- # Started once the lobby exists, since that is what it watches - which is now # after the readiness wait above rather than after the host launch, because # that is where "the lobby exists" became true. It reads # limits.idleTimeoutSeconds and exits immediately when the manifest omits it, so # this costs a manifest without one nothing but a process that starts and stops. # # It shuts the match down by signalling us, not by killing anything: TERM here # runs shutdown() above, which finalizes first. Hence the exported PID - we are # PID 1 in the image but not when run from a checkout. export ARENA_SUPERVISOR_PID=$$ "$ARENA_HOME/container/watch/idle.sh" & IDLE_PID=$! # --- wait ------------------------------------------------------------------- # `wait` on a specific PID so a support process exiting does not unblock us. set +e wait "$HOST_PID" HOST_RC=$? set -e log "match host exited rc=$HOST_RC" # The idle timeout has nothing left to guard once the host is gone, and it is # stopped before finalize rather than with the others after it: a TERM landing # mid-finalize would run the trap on the way out and replace the host's exit # status with 143. [ -n "$IDLE_PID" ] && kill -TERM "$IDLE_PID" 2>/dev/null || true IDLE_PID="" # Suramadu is normally stopped after finalize, below. The client JVM only writes # its profile as it exits, so on a profiled run it has to go first. if profiling; then flush_profiles; fi finalize_once for pid in "$RESULTS_PID" "$WATCHER_PID" "$WS_PID"; do [ -n "$pid" ] && kill -TERM "$pid" 2>/dev/null || true done emit exiting "rc=$HOST_RC" exit "$HOST_RC"