Files

312 lines
13 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
source script/playable_process_cleanup.sh
usage() {
cat <<'EOF'
用法: script/run_client_gate.sh --app APP --config CONFIG [选项]
选项:
--output DIR 本次运行目录,必须不存在或为空(默认 build/playable/run-<时间>-<pid>
--suite playable|full|soak 必测用例集合,默认 playablesoak 需 --timeout-seconds >= soak.duration_seconds+900
--mode MODE 包内 MT_TEST_MODE,默认 playable
--assets DIR 资源预检根目录(默认包内 Contents/Resources/assets
--serverlist FILE 测试模式 serverlist 覆盖;预检与客户端使用同一文件
--timeout-seconds N 墙钟超时,默认 900;超时 TERM10 秒后 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
}
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 &
proxy_pid=$!
proxy_wait=0
until grep -q '"action":"listening"' "$output_dir/fault-proxy.jsonl" 2>/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 </dev/null &
child_pid=$!
sampler_pid=""
if [ "$suite" = "soak" ]; then
node script/playable_soak_metrics.mjs --sample-rss --pid "$child_pid" --output "$output_dir/rss.jsonl" \
</dev/null >>"$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=$?
child_pid=""
# 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=$?
redactor_pid=""
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"
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"