- 新增 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
194 lines
8.9 KiB
Bash
194 lines
8.9 KiB
Bash
#!/usr/bin/env bash
|
||
# STB-01 public entry: 2 h wall-clock soak plus independent normal-exit runs.
|
||
#
|
||
# Layout under --output (must be empty):
|
||
# exit-<n>/ N independent client processes, suite playable, each through
|
||
# run_client_gate.sh (normal quit, exit 0, no leak/RID warnings)
|
||
# soak-1/ one client process, suite soak: move/combat/pickup rounds until the
|
||
# wall-clock duration, active reconnects, resolution switches, and —
|
||
# only when the scenario confirms them — portal warps and real
|
||
# transport faults through the 127.0.0.1 fault proxy; RSS is sampled
|
||
# for that child PID and sealed as STB-MEMORY-01
|
||
# release-manifest.json listing exactly these runs; aggregated with
|
||
# validate_playable_report.mjs --release-dir (no historical reports are scanned).
|
||
#
|
||
# Exit runs go first; if one is not PASS the remaining runs (including the 2 h soak)
|
||
# are not started and are recorded as BLOCKED so the release cannot pass.
|
||
# Credentials only from MT_ACCOUNT/MT_PASSWORD or one hidden TTY prompt.
|
||
# Do not add `set -x` to this script. Never pkill; never touch routes/firewall/caches.
|
||
#
|
||
# exit: 0 PASS, 1 FAIL, 2 BLOCKED (config/precondition/unconfirmed environment), 124 timeout.
|
||
set -euo pipefail
|
||
set +x
|
||
cd "$(dirname "$0")/.."
|
||
repo="$PWD"
|
||
app="${MT_PLAYABLE_APP:-}"
|
||
config=""
|
||
output=""
|
||
duration=""
|
||
exits=""
|
||
timeout=""
|
||
exit_timeout="900"
|
||
allow=0
|
||
assets=""
|
||
serverlist="${MT_PLAYABLE_SERVERLIST:-}"
|
||
|
||
usage() {
|
||
cat <<'EOF'
|
||
用法: script/playable_soak.sh --app APP --config CONFIG --allow-gameplay [选项]
|
||
--app APP 候选导出包(所有 run 必须同一哈希)
|
||
--config CONFIG 场景 JSON(含 soak 块;不含凭据)
|
||
--output DIR 本批次根目录,必须不存在或为空(默认 build/playable/soak-<时间>-<pid>)
|
||
--duration-seconds N 墙钟时长,必须等于配置 soak.duration_seconds(默认取配置,>= 7200)
|
||
--exits N 独立正常退出运行次数(默认配置 soak.exits,>= 10)
|
||
--timeout-seconds N soak 进程墙钟超时(默认 duration+900,不得更小)
|
||
--exit-timeout-seconds N 每个退出运行的墙钟超时,默认 900
|
||
--assets DIR 资源预检根目录(透传 run_client_gate.sh)
|
||
--serverlist FILE 测试模式 serverlist 覆盖(透传)
|
||
--allow-gameplay 允许专用测试账号发送移动/战斗/拾取请求(不给则只校验配置,返回 2,不启动客户端)
|
||
环境变量:
|
||
MT_ACCOUNT / MT_PASSWORD 测试账号(未设置且 stdin 是终端时隐藏输入一次)
|
||
说明:
|
||
切图(soak.warp)和真实断网(soak.faults)须由环境负责人确认后在配置里标 confirmed;
|
||
未确认时对应用例为 BLOCKED,不用主动重连或直接传送替代。
|
||
退出码: 0 PASS / 1 FAIL / 2 BLOCKED / 124 超时
|
||
EOF
|
||
}
|
||
|
||
blocked() { echo "PLAYABLE SOAK: BLOCKED $*" >&2; exit 2; }
|
||
positive() { [[ "$1" =~ ^[1-9][0-9]*$ ]]; }
|
||
|
||
while [ "$#" -gt 0 ]; do
|
||
case "$1" in
|
||
--app) app="${2:-}"; shift 2 ;;
|
||
--config) config="${2:-}"; shift 2 ;;
|
||
--output) output="${2:-}"; shift 2 ;;
|
||
--duration-seconds) duration="${2:-}"; shift 2 ;;
|
||
--exits) exits="${2:-}"; shift 2 ;;
|
||
--timeout-seconds) timeout="${2:-}"; shift 2 ;;
|
||
--exit-timeout-seconds) exit_timeout="${2:-}"; shift 2 ;;
|
||
--assets) assets="${2:-}"; shift 2 ;;
|
||
--serverlist) serverlist="${2:-}"; shift 2 ;;
|
||
--allow-gameplay) allow=1; shift ;;
|
||
--help) usage; exit 0 ;;
|
||
*) echo "未知选项: $1" >&2; usage >&2; exit 2 ;;
|
||
esac
|
||
done
|
||
|
||
[ -n "$app" ] && [ -d "$app" ] || blocked "需要 --app 指向候选导出包"
|
||
[ -n "$config" ] && [ -f "$config" ] || blocked "需要 --config CONFIG"
|
||
config="$(cd "$(dirname "$config")" && pwd)/$(basename "$config")"
|
||
for pair in "duration-seconds:$duration" "exits:$exits" "timeout-seconds:$timeout"; do
|
||
value="${pair#*:}"
|
||
if [ -n "$value" ] && ! positive "$value"; then blocked "--${pair%%:*} 必须是正整数"; fi
|
||
done
|
||
positive "$exit_timeout" || blocked "--exit-timeout-seconds 必须是正整数"
|
||
|
||
# Config validation before any prompt or process: the same gate the runner uses.
|
||
if [ -z "$assets" ]; then
|
||
if [ -d "$app/Contents/Resources/assets" ]; then assets="$app/Contents/Resources/assets"; else assets="$repo/assets"; fi
|
||
fi
|
||
if ! config_log="$(env MT_PLAYABLE_VALIDATE_CONFIG="$config" MT_PLAYABLE_SUITE=soak MT_PLAYABLE_ASSETS="$assets" \
|
||
MT_PLAYABLE_SERVERLIST="$serverlist" "${MT_GODOT:-godot}" --headless --path project --script playable_config_gate.gd 2>&1)"; then
|
||
printf '%s\n' "$config_log" | grep '^CONFIG:' >&2 || true
|
||
blocked "配置/资源预检失败(suite soak)"
|
||
fi
|
||
read -r cfg_duration cfg_exits <<<"$(node -e '
|
||
const s = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")).soak;
|
||
process.stdout.write(`${s.duration_seconds} ${s.exits}`);
|
||
' "$config")"
|
||
if [ -z "$duration" ]; then duration="$cfg_duration"; fi
|
||
[ "$duration" -eq "$cfg_duration" ] || blocked "--duration-seconds $duration 与配置 soak.duration_seconds $cfg_duration 不一致;请改配置而不是覆盖"
|
||
if [ -z "$exits" ]; then exits="$cfg_exits"; fi
|
||
[ "$exits" -ge 10 ] || blocked "--exits 至少 10"
|
||
minimum_timeout=$((duration + 900))
|
||
if [ -z "$timeout" ]; then timeout="$minimum_timeout"; fi
|
||
[ "$timeout" -ge "$minimum_timeout" ] || blocked "--timeout-seconds 不得小于 duration+900(${minimum_timeout})"
|
||
# Without explicit consent only the configuration is checked; no client, no gameplay request.
|
||
[ "$allow" -eq 1 ] || blocked "配置校验通过;soak 需要 --allow-gameplay(专用测试账号上的移动/战斗/拾取),未启动客户端"
|
||
|
||
root="$output"
|
||
if [ -z "$root" ]; then root="$repo/build/playable/soak-$(date +%Y%m%d-%H%M%S)-$$"; fi
|
||
if [ -e "$root" ] && [ -n "$(find "$root" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
|
||
blocked "输出目录已有内容,拒绝混入历史运行:$root"
|
||
fi
|
||
mkdir -p "$root"
|
||
root="$(cd "$root" && pwd)"
|
||
|
||
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
|
||
|
||
bash script/playable_build_info.sh "$app" >"$root/candidate.json"
|
||
|
||
runs_json="[]"
|
||
timed_out=0
|
||
stop_reason=""
|
||
list_run() { # name suite
|
||
runs_json="$(node -e '
|
||
const fs = require("node:fs");
|
||
const [runs, dir, name, suite] = process.argv.slice(1);
|
||
let runId = `${name}-unsealed`;
|
||
try { runId = JSON.parse(fs.readFileSync(`${dir}/report.json`, "utf8")).run_id || runId; } catch {}
|
||
const list = JSON.parse(runs);
|
||
list.push({ run_id: runId, suite, path: name });
|
||
process.stdout.write(JSON.stringify(list));
|
||
' "$runs_json" "$root/$1" "$1" "$2")"
|
||
}
|
||
run_one() { # name suite timeout
|
||
local name="$1" run_suite="$2" run_timeout="$3" code
|
||
if [ -n "$stop_reason" ]; then
|
||
mkdir -p "$root/$name"
|
||
echo "BLOCKED not started: $stop_reason" >"$root/$name/gate.log"
|
||
list_run "$name" "$run_suite"
|
||
return
|
||
fi
|
||
local args=(--app "$app" --config "$config" --output "$root/$name" --suite "$run_suite" --timeout-seconds "$run_timeout" --allow-gameplay)
|
||
if [ -n "$assets" ]; then args+=(--assets "$assets"); fi
|
||
if [ -n "$serverlist" ]; then args+=(--serverlist "$serverlist"); fi
|
||
set +e
|
||
bash script/run_client_gate.sh "${args[@]}" </dev/null
|
||
code=$?
|
||
set -e
|
||
if [ "$code" -eq 124 ]; then timed_out=1; fi
|
||
if [ "$code" -ne 0 ] && [ "$run_suite" = "playable" ]; then stop_reason="$name exit $code"; fi
|
||
list_run "$name" "$run_suite"
|
||
}
|
||
|
||
for index in $(seq 1 "$exits"); do
|
||
run_one "exit-$index" playable "$exit_timeout"
|
||
done
|
||
run_one soak-1 soak "$timeout"
|
||
|
||
node -e '
|
||
const fs = require("node:fs");
|
||
const [root, exits, runs] = process.argv.slice(1);
|
||
const candidate = JSON.parse(fs.readFileSync(`${root}/candidate.json`, "utf8"));
|
||
fs.writeFileSync(`${root}/release-manifest.json`, JSON.stringify({
|
||
schema_version: 1, candidate, required_runs: { playable: Number(exits), soak: 1 }, runs: JSON.parse(runs),
|
||
}, null, 2) + "\n");
|
||
' "$root" "$exits" "$runs_json"
|
||
|
||
set +e
|
||
node script/validate_playable_report.mjs --release-dir "$root"
|
||
release_code=$?
|
||
set -e
|
||
if [ -f "$root/soak-1/report.json" ]; then
|
||
node -e '
|
||
const r = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8"));
|
||
for (const c of [...(r.cases || []).filter((c) => c.id.startsWith("STB-")), ...(r.runner_cases || [])]) console.log(` ${c.id} ${c.status} ${c.reason || ""}`);
|
||
' "$root/soak-1/report.json" || true
|
||
fi
|
||
if [ "$timed_out" -eq 1 ]; then echo "PLAYABLE SOAK: TIMEOUT root=$root" >&2; exit 124; fi
|
||
case "$release_code" in
|
||
0) echo "PLAYABLE SOAK: PASS exits=$exits duration=${duration}s root=$root" ;;
|
||
2) echo "PLAYABLE SOAK: BLOCKED root=$root" >&2 ;;
|
||
*) echo "PLAYABLE SOAK: FAIL root=$root" >&2; release_code=1 ;;
|
||
esac
|
||
exit "$release_code"
|