#!/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 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" process_cleanup_grace_seconds=15 # Allow the child runner's 10-second cleanup to finish. source script/playable_process_cleanup.sh 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-<时间>-) --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[@]}" 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"