#!/usr/bin/env bash # INF-02: run ONE exported client process and seal its report after it exits. # # - Owns exactly one child PID (plus its own log redactor); never pkills other # clients, never touches routes/firewall/system caches. # - Credentials only from MT_ACCOUNT/MT_PASSWORD or a hidden TTY prompt; never # from arguments or files. Do not add `set -x` to this script. # - The client writes client-report.json; this script writes the final # report.json only after the real process exit and the full-log gate. # - suite soak (STB-01): also samples RSS of the child PID once per second into # rss.jsonl, seals STB-MEMORY-01 into report.json (can only downgrade), and when # soak.faults is confirmed runs script/playable_fault_proxy.mjs on 127.0.0.1 for # this client's connection only (for every suite, since server.* then points at the # proxy). Sampler and proxy are this script's own children. # # exit: 0 PASS, 1 assert/exit-gate FAIL, 2 config/precondition BLOCKED, 124 wall-clock timeout. set -euo pipefail set +x cd "$(dirname "$0")/.." repo="$PWD" app_path="${MT_PLAYABLE_APP:-}" mode="playable" suite="playable" config_path="" assets_root="" serverlist="${MT_PLAYABLE_SERVERLIST:-}" output_dir="" timeout_seconds="${MT_PLAYABLE_TIMEOUT_SECONDS:-900}" allow_gameplay=0 required_arch="${MT_PLAYABLE_REQUIRED_ARCH:-arm64}" godot_bin="${MT_GODOT:-godot}" kill_grace_seconds=10 usage() { cat <<'EOF' 用法: script/run_client_gate.sh --app APP --config CONFIG [选项] 选项: --output DIR 本次运行目录,必须不存在或为空(默认 build/playable/run-<时间>-) --suite playable|full|soak 必测用例集合,默认 playable;soak 需 --timeout-seconds >= soak.duration_seconds+900 --mode MODE 包内 MT_TEST_MODE,默认 playable --assets DIR 资源预检根目录(默认包内 Contents/Resources/assets) --serverlist FILE 测试模式 serverlist 覆盖;预检与客户端使用同一文件 --timeout-seconds N 墙钟超时,默认 900;超时 TERM,10 秒后 KILL --allow-gameplay 允许专用测试账号发送移动/战斗/拾取/施法请求 环境变量: MT_ACCOUNT / MT_PASSWORD 测试账号(未设置且 stdin 是终端时隐藏输入;否则返回 2) MT_PLAYABLE_REQUIRED_ARCH 包必须包含的架构,默认 arm64 退出码: 0 PASS / 1 FAIL / 2 BLOCKED(配置或前置条件)/ 124 墙钟超时 EOF } while [ "$#" -gt 0 ]; do case "$1" in --app) app_path="${2:-}"; shift 2 ;; --config) config_path="${2:-}"; shift 2 ;; --output) output_dir="${2:-}"; shift 2 ;; --suite) suite="${2:-}"; shift 2 ;; --mode) mode="${2:-}"; shift 2 ;; --assets) assets_root="${2:-}"; shift 2 ;; --serverlist) serverlist="${2:-}"; shift 2 ;; --timeout-seconds) timeout_seconds="${2:-}"; shift 2 ;; --allow-gameplay) allow_gameplay=1; shift ;; --help) usage; exit 0 ;; *) echo "未知选项: $1" >&2; usage >&2; exit 2 ;; esac done output_ready=0 blocked() { echo "PLAYABLE GATE: BLOCKED $*" >&2 if [ "$output_ready" -eq 1 ]; then echo "BLOCKED $*" >>"$output_dir/gate.log"; fi exit 2 } [ -n "$app_path" ] || blocked "需要 --app" [ -n "$config_path" ] && [ -f "$config_path" ] || blocked "配置不存在;不会启动客户端" [ "$suite" = "playable" ] || [ "$suite" = "full" ] || [ "$suite" = "soak" ] || blocked "--suite 只支持 playable/full/soak" [[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]] || blocked "--timeout-seconds 必须是正整数" config_path="$(cd "$(dirname "$config_path")" && pwd)/$(basename "$config_path")" engine="$app_path/Contents/MacOS/mtgodot-poc" [ -x "$engine" ] || blocked "找不到可执行包:$app_path" if [ -n "$serverlist" ]; then [ -f "$serverlist" ] || blocked "serverlist 不存在" serverlist="$(cd "$(dirname "$serverlist")" && pwd)/$(basename "$serverlist")" fi if [ -z "$output_dir" ]; then output_dir="$repo/build/playable/run-$(date +%Y%m%d-%H%M%S)-$$" fi if [ -e "$output_dir" ] && [ -n "$(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]; then blocked "输出目录已有内容,拒绝复用历史报告:$output_dir" fi mkdir -p "$output_dir" output_dir="$(cd "$output_dir" && pwd)" output_ready=1 # 2. package identity: signature and architecture before anything runs. if ! codesign --verify --strict "$app_path" >/dev/null 2>&1; then blocked "签名校验失败:codesign --verify --strict $app_path" fi archs="$(lipo -archs "$engine" 2>/dev/null || true)" case " $archs " in *" $required_arch "*) ;; *) blocked "包架构 [$archs] 不含 $required_arch" ;; esac run_id="$(basename "$output_dir")-$(od -An -N4 -tx4 /dev/urandom | tr -d ' ')" log="$output_dir/client.log" client_report="$output_dir/client-report.json" final_report="$output_dir/report.json" events="$output_dir/events.jsonl" if [ -z "$assets_root" ]; then if [ -d "$app_path/Contents/Resources/assets" ]; then assets_root="$app_path/Contents/Resources/assets" else assets_root="$repo/assets" fi fi # 3. config + address + resource preconditions; resolves the address the client will use. if ! env MT_PLAYABLE_VALIDATE_CONFIG="$config_path" MT_PLAYABLE_SUITE="$suite" \ MT_PLAYABLE_ASSETS="$assets_root" MT_PLAYABLE_SERVERLIST="$serverlist" \ MT_PLAYABLE_GATE_OUTPUT="$output_dir" \ "$godot_bin" --headless --path project --script playable_config_gate.gd >"$output_dir/config.log" 2>&1; then blocked "配置/资源预检失败;不会启动客户端:$output_dir/config.log" fi # The config gate validated any soak block (for every suite); read only the fields the runner needs. read -r soak_duration soak_warmup soak_min_rounds fault_status up_auth_host up_auth_port up_game_host up_game_port <<<"$(node -e ' const c = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); const s = c.soak || {}; const f = s.faults || {}; const u = f.upstream || {}; process.stdout.write([s.duration_seconds || 0, s.warmup_rounds ?? 1, s.min_rounds || 10, f.status || "unconfirmed", u.auth_host || "-", u.auth_port || 0, u.game_host || "-", u.game_port || 0].join(" ")); ' "$config_path")" if [ "$suite" = "soak" ]; then [ "$timeout_seconds" -ge $((soak_duration + 900)) ] || blocked "soak 需要 --timeout-seconds >= soak.duration_seconds + 900(当前 ${timeout_seconds})" fi # Credentials: env or hidden prompt only. if [ -z "${MT_ACCOUNT:-}" ] || [ -z "${MT_PASSWORD:-}" ]; then if [ -t 0 ]; then if [ -z "${MT_ACCOUNT:-}" ]; then read -r -s -p "测试账号: " MT_ACCOUNT; echo >&2; fi if [ -z "${MT_PASSWORD:-}" ]; then read -r -s -p "测试密码: " MT_PASSWORD; echo >&2; fi fi fi [ -n "${MT_ACCOUNT:-}" ] && [ -n "${MT_PASSWORD:-}" ] || blocked "缺少 MT_ACCOUNT/MT_PASSWORD,且不是交互终端" export MT_ACCOUNT MT_PASSWORD read_address() { node -e ' const a = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); process.stdout.write([a.auth_host, a.auth_port, a.game_host, a.game_port].join(" ")); ' "$output_dir/server-address.json" } read -r auth_host auth_port game_host game_port <<<"$(read_address)" proxy_pid="" stop_proxy() { if [ -n "$proxy_pid" ]; then kill -TERM "$proxy_pid" 2>/dev/null || true wait "$proxy_pid" 2>/dev/null || true proxy_pid="" fi } trap stop_proxy EXIT if [ "$fault_status" = "confirmed" ]; then # Loopback proxy in front of the test server for this client only. server.* points at it, so # playable exit runs sharing the scenario go through it too; only the soak client requests faults. for endpoint in "$up_auth_host:$up_auth_port" "$up_game_host:$up_game_port"; do if ! nc -z -G 3 "${endpoint%:*}" "${endpoint##*:}" >/dev/null 2>&1; then blocked "故障代理上游不可达:$endpoint" fi done routes=(--route "$auth_port=$up_auth_host:$up_auth_port") if [ "$game_port" != "$auth_port" ]; then routes+=(--route "$game_port=$up_game_host:$up_game_port") elif [ "$up_game_host:$up_game_port" != "$up_auth_host:$up_auth_port" ]; then blocked "auth/game 共用代理端口,但上游地址不同" fi node script/playable_fault_proxy.mjs "${routes[@]}" --events "$events" --log "$output_dir/fault-proxy.jsonl" \ --run-id "$run_id" >"$output_dir/fault-proxy.log" 2>&1 /dev/null; do if ! kill -0 "$proxy_pid" 2>/dev/null || [ "$proxy_wait" -ge 50 ]; then blocked "故障代理未能在 127.0.0.1 监听:$output_dir/fault-proxy.log" fi sleep 0.1 proxy_wait=$((proxy_wait + 1)) done echo "FAULT PROXY pid=$proxy_pid listen=127.0.0.1:$auth_port,$game_port" >>"$output_dir/gate.log" fi for endpoint in "$auth_host:$auth_port" "$game_host:$game_port"; do if ! nc -z -G 3 "${endpoint%:*}" "${endpoint##*:}" >/dev/null 2>&1; then blocked "服务器端口不可达:$endpoint" fi done char_slot="$(node -e 'process.stdout.write(String(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8")).character_slot))' "$config_path")" # 1. identity of this run. bash script/playable_build_info.sh "$app_path" >"$output_dir/build.json" build_field() { node -e 'process.stdout.write(String(JSON.parse(require("node:fs").readFileSync(process.argv[1],"utf8"))[process.argv[2]]))' "$output_dir/build.json" "$1"; } export MT_TEST_MODE="$mode" export MT_PLAYABLE_SUITE="$suite" export MT_PLAYABLE_CONFIG="$config_path" export MT_PLAYABLE_RUN_ID="$run_id" export MT_PLAYABLE_SERVERLIST="$serverlist" export MT_TEST_REPORT="$client_report" export MT_TEST_EVENTS="$events" export MT_TEST_OUTPUT="$output_dir" export MT_PROTOCOL="classic" export MT_AUTOLOGIN=1 export MT_CHAR_SLOT="$char_slot" export MT_BUILD_ENGINE_SHA256="$(build_field engine_sha256)" export MT_BUILD_EXTENSION_SHA256="$(build_field extension_sha256)" export MT_BUILD_PCK_SHA256="$(build_field pck_sha256)" export MT_BUILD_ARCH="$(build_field arch)" export MT_PLAYABLE_ALLOW_GAMEPLAY="$allow_gameplay" # 4/5. child stdout+stderr -> FIFO -> redactor -> client.log. The child PID is # the engine itself (redirections are applied before exec), so `wait` returns # its real status regardless of the log pipe. fifo="$output_dir/.client.fifo" mkfifo "$fifo" started_ms="$(($(date +%s) * 1000))" set +e node script/redact_stream.mjs <"$fifo" >"$log" & redactor_pid=$! env -u MT_ASSETS "$engine" >"$fifo" 2>&1 >"$output_dir/gate.log" 2>&1 & sampler_pid=$! fi SECONDS=0 timed_out=0 while kill -0 "$child_pid" 2>/dev/null; do if [ "$SECONDS" -ge "$timeout_seconds" ]; then timed_out=1 kill -TERM "$child_pid" 2>/dev/null grace=0 while kill -0 "$child_pid" 2>/dev/null && [ "$grace" -lt "$kill_grace_seconds" ]; do sleep 1 grace=$((grace + 1)) done killed=0 if kill -0 "$child_pid" 2>/dev/null; then kill -KILL "$child_pid" 2>/dev/null; killed=1; fi echo "TIMEOUT term_grace_s=$grace killed=$killed" >>"$output_dir/gate.log" break fi sleep 1 done wait "$child_pid" process_code=$? # The redactor ends at EOF; bound the wait in case a grandchild kept the FIFO open. redactor_wait=0 while kill -0 "$redactor_pid" 2>/dev/null && [ "$redactor_wait" -lt 10 ]; do sleep 1 redactor_wait=$((redactor_wait + 1)) done if kill -0 "$redactor_pid" 2>/dev/null; then kill -TERM "$redactor_pid" 2>/dev/null echo "PLAYABLE GATE: log redactor did not reach EOF (FIFO held open)" >>"$output_dir/gate.log" fi wait "$redactor_pid" redactor_code=$? rm -f "$fifo" if [ -n "$sampler_pid" ]; then # The sampler stops by itself once the child is gone; bound the wait anyway. sampler_wait=0 while kill -0 "$sampler_pid" 2>/dev/null && [ "$sampler_wait" -lt 10 ]; do sleep 1 sampler_wait=$((sampler_wait + 1)) done if kill -0 "$sampler_pid" 2>/dev/null; then kill -TERM "$sampler_pid" 2>/dev/null; fi wait "$sampler_pid" fi stop_proxy node script/validate_playable_report.mjs --report "$client_report" --output "$final_report" \ --log "$log" --process-code "$process_code" --timed-out "$timed_out" --run-id "$run_id" \ --suite "$suite" --required-cases "$output_dir/required-cases.json" --build "$output_dir/build.json" \ --events "$events" --started-ms "$started_ms" --redactor-code "$redactor_code" --require-pass gate_status=$? if [ "$suite" = "soak" ]; then node script/playable_soak_metrics.mjs --seal --report "$final_report" --rss "$output_dir/rss.jsonl" --events "$events" \ --warmup-rounds "$soak_warmup" --min-rounds "$soak_min_rounds" memory_status=$? if [ "$gate_status" -ne 0 ] && [ "$gate_status" -ne 2 ]; then gate_status=1 elif [ "$memory_status" -ne 0 ] && [ "$memory_status" -ne 2 ]; then gate_status=1 elif [ "$gate_status" -eq 2 ] || [ "$memory_status" -eq 2 ]; then gate_status=2 fi fi set -e if [ "$timed_out" -eq 1 ]; then echo "PLAYABLE GATE: TIMEOUT raw_exit=$process_code report=$final_report log=$log" >&2 exit 124 fi case "$gate_status" in 0) echo "PLAYABLE GATE: PASS run_id=$run_id report=$final_report" ;; 2) echo "PLAYABLE GATE: BLOCKED raw_exit=$process_code report=$final_report" >&2 ;; *) echo "PLAYABLE GATE: FAIL raw_exit=$process_code report=$final_report log=$log" >&2; gate_status=1 ;; esac exit "$gate_status"