feat(playable): 首个 Mac 联网内测版 STB-01 soak 工具与发布状态文档

- 新增 script/playable_soak.sh:先做 soak 配置/资源校验,无 --allow-gameplay
  只校验不启动客户端;N(>=10) 次独立正常退出运行 + 墙钟 soak,失败即停止后续
  运行并记 BLOCKED;写本批次 release-manifest.json 后聚合
- run_client_gate.sh:确认的故障代理对所有 suite 启动(共享场景的退出运行也经代理);
  soak 超时下限只约束 soak 客户端(validate_soak 增加 soak_client 参数)
- 新增本地 127.0.0.1 故障代理、RSS 采样/内存判定、窗口/指标/流程模块及其测试
- forest_mob_render_test 输出 PASS/FAIL 标记,供 rendering_batch_test.sh 识别
- 新增 docs/FIRST-MAC-PLAYABLE-STATUS.md:如实记录 PASS/BLOCKED、已知问题与环境需求
- .gitignore 排除本地场景配置、凭据文件与运行输出

离线回归:rendering_batch_test.sh failures=0,playable_gate_test.sh PASS,
node 夹具测试 PASS。未联网运行,未重建候选包。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ft3kXpNdLbKEfg5uDLfqVF
This commit is contained in:
shenlei
2026-09-11 22:06:48 +09:00
co-authored by Claude Opus 5
parent 374d4165d8
commit f39a55fdd5
66 changed files with 9189 additions and 951 deletions
+250 -67
View File
@@ -1,125 +1,308 @@
#!/usr/bin/env bash
# Run one exported client process and seal its report after exit.
# This script owns exactly one child PID; it never pkills other clients.
# 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")/.."
app_path="${MT_PLAYABLE_APP:-$PWD/build/export-native-trees-20260911/mtgodot-poc.app}"
repo="$PWD"
app_path="${MT_PLAYABLE_APP:-}"
mode="playable"
suite="playable"
config_path=""
output_dir="${MT_PLAYABLE_OUTPUT:-$PWD/build/playable/run-$(date +%Y%m%d-%H%M%S)-$$}"
timeout_seconds="${MT_PLAYABLE_TIMEOUT_SECONDS:-180}"
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 本次运行目录,必须不存在或为空
--mode MODE 默认 playable
--timeout-seconds N 墙钟超时,默认 180
--allow-gameplay 允许专用测试账号发送移动/战斗/拾取请求
--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 ;;
--mode) mode="$2"; shift 2 ;;
--timeout-seconds) timeout_seconds="$2"; shift 2 ;;
--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
if [ -z "$config_path" ] || [ ! -f "$config_path" ]; then
echo "配置不存在;不会启动客户端:$config_path" >&2
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 [ ! -x "$app_path/Contents/MacOS/mtgodot-poc" ]; then
echo "找不到可执行包:$app_path" >&2
exit 2
if [ -z "$output_dir" ]; then
output_dir="$repo/build/playable/run-$(date +%Y%m%d-%H%M%S)-$$"
fi
if ! [[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]]; then
echo "timeout-seconds 必须是正整数" >&2
exit 2
fi
if [ -e "$output_dir" ] && [ "$(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
echo "输出目录已有内容,拒绝复用历史报告:$output_dir" >&2
exit 2
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 ! MT_PLAYABLE_VALIDATE_CONFIG="$config_path" godot --headless --path project --script playable_config_gate.gd >"$output_dir/config.log" 2>&1; then
echo "配置校验失败;不会启动客户端:$output_dir/config.log" >&2
exit 2
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
char_slot="$(node -e 'const fs=require("node:fs"); const p=process.argv[1]; const d=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(String(d.character_slot));' "$config_path")"
sha256_of() {
if [ -f "$1" ]; then shasum -a 256 "$1" | awk '{print $1}'; else echo ""; 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"
}
engine="$app_path/Contents/MacOS/mtgodot-poc"
pck="$app_path/Contents/Resources/mtgodot-poc.pck"
dylib="$(find "$app_path/Contents/Frameworks" -maxdepth 1 -type f -name '*.dylib' -print -quit)"
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 &
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_PLAYABLE_RUN_ID="$(basename "$output_dir")"
export MT_BUILD_ENGINE_SHA256="$(sha256_of "$engine")"
export MT_BUILD_EXTENSION_SHA256="$(sha256_of "$dylib")"
export MT_BUILD_PCK_SHA256="$(sha256_of "$pck")"
file_description="$(file "$engine")"
case "$file_description" in
*arm64*) export MT_BUILD_ARCH="arm64" ;;
*x86_64*) export MT_BUILD_ARCH="x86_64" ;;
*) export MT_BUILD_ARCH="unknown" ;;
esac
if [ "$allow_gameplay" -eq 1 ]; then export MT_PLAYABLE_ALLOW_GAMEPLAY=1; else export MT_PLAYABLE_ALLOW_GAMEPLAY=0; fi
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"
start_epoch="$(date +%s)"
# 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
env -u MT_ASSETS "$engine" >"$log" 2>&1 &
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
now="$(date +%s)"
if [ $((now - start_epoch)) -ge "$timeout_seconds" ]; then
if [ "$SECONDS" -ge "$timeout_seconds" ]; then
timed_out=1
kill -TERM "$child_pid" 2>/dev/null || true
for _ in 1 2 3 4 5 6 7 8 9 10; do
kill -0 "$child_pid" 2>/dev/null || break
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
kill -KILL "$child_pid" 2>/dev/null || true
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"
app_status=$?
set -e
if [ "$timed_out" -eq 1 ]; then app_status=124; fi
set +e
node script/validate_playable_report.mjs --report "$client_report" --output "$final_report" \
--log "$log" --process-code "$app_status" --require-pass
gate_status=$?
set -e
if [ "$app_status" -ne 0 ] || [ "$gate_status" -ne 0 ]; then
echo "PLAYABLE GATE: FAIL exit=$app_status report=$final_report log=$log" >&2
exit 1
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
echo "PLAYABLE GATE: PASS report=$final_report log=$log"
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"