312 lines
13 KiB
Bash
Executable File
312 lines
13 KiB
Bash
Executable File
#!/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 必测用例集合,默认 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
|
||
}
|
||
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"
|