Files
DCU_DesktopController/scripts/check_guard_fires_hw.py
T
cbb 83a035c6d6 test(guard): 验收链改名入库 + 补真退出码;tx 冻结守卫真机门槛两段
之前 e2e_web_send_latency.py / recover_ecat_after_reset.py / measure_click_to_motion.py
全在 .gitignore 里(e2e_/measure_ 通配),换台机器 clone 就跑不了验收;且
recover 脚本零失败路径(--skip-e2e 无条件退 0)。现在统一到已跟踪的 check_ 前缀,
.gitignore 一行例外都不用加,并把"跑过了"和"恢复了"绑成一件事。

- check_recover_after_reset.py:force_reopen 后改为轮询到 dc=1/250us/loop=1/op=1/
  wkc>=3(上限 15s,替掉原来单点采样那次假 loop=0),并断言守卫心跳在涨、
  发一帧 3s 内出口必须跟上;退出码 0/1/2(前置不满足=BLOCKED=2)。
- check_web_send_latency.py:新增第 6 段读 health.guard(旧 exe 无 guard 块 /
  alive_ticks 不涨 都算 FAIL),第 5 段改调已入库的 check_click_to_motion.py。
  它每次发送只等 2s,结构上看不到 8s 判据,所以只报"守卫当时是死是活",
  不拿它冒充触发验证。
- check_guard_idle_silence.py:健康总线的"另一极"——90s 静默 + 25s 1Hz 发送,
  要求心跳在涨、armed 恒 0、fires/wr 零增量、每次发送都被消费。这是防
  "把守卫修成重连风暴"的真机版(对应离线 T1b/T4/T5/T6)。
- check_guard_fires_hw.py:需人在场按 GD32 RESET 制造冻结,证明 armed 秒起、
  tx_stale_s 爬过 8s、fires +1、日志有 [GUARD] 全量重连、松手后 20s 内出口
  重新前进,再验证 60s 冷却内不第二次触发;--with-pm-hold 另证"静音"时心跳仍在涨。
  只认 guard.fires,不认 wr(wr 汇总所有恢复路径,会以错误的理由通过)。
- guard_probe.py:两个真机脚本共用同一份前置判据与通路选择(先 :2625 页面代理,
  再直连 native 8090..8095)。前置没满足一律 BLOCKED——loop=0 或 pm_hold=1 时判据
  按设计是静音的,那种"绿了"毫无意义,所以判据只放一个执行点。
2026-09-21 23:30:07 +08:00

210 lines
9.0 KiB
Python
Raw 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.
"""真机正向验证:主动制造输出冻结,证明守卫**真的**会触发,且 60s 内不第二次触发。
这套判据历史上"修过一次又复发",而复发时页面全绿、wr 恒 0,没有任何测试断言它
必须触发。纯逻辑门槛(test_guard_logic)已经能离线判死,但它证明的是"函数",
这条证明的是"函数确实挂在这台机器的总线上工作"。
流程(两处物理动作都要人在场,脚本会停下来等):
1. 前置:bus/op/loop=1、dc=250us、wkc>=3、pm_hold=0、recovering=0、心跳在涨;
并要求 since_full_s>61(60s 冷却是进程全局的,早先任何一次恢复都会吃掉预算)
2. 证明通路活着:发一帧,出口 tx_count/ack 必须动
3. → 请按 GD32 的 RESET:主站继续打包(cycles 涨)而出口冻住 = 现场症状复现
4. 只发一次(就是"点一下"),然后盯 guard:armed 必须在 1s 内起来,
tx_stale_s 必须爬过 8s,fires 必须 +1(预算 25s:按 GD32 复位会连输入一起冻,
输入侧判据可能先做一次 OP-only 恢复,其 10s 冷却会把本判据的补触发往后推)
5. → 请让 GD32 重新跑起 APP:出口必须重新前进、dc/wkc/loop 恢复、再发一帧拿回显
6. 冷却:从触发那一刻起每 5s 一帧共 70s,fires 增量必须仍为 1
7.(可选 --with-pm-hold)再来一轮,但先 /api/pm/hold=1:要求 fires 零增量**且心跳
在涨** —— 证明"静音"是设计而不是线程死亡
用法:python scripts/check_guard_fires_hw.py [--with-pm-hold]
退出码:0=PASS 1=FAIL 2=BLOCKED(前置/不在场)。末行恒为 VERDICT。
"""
import os
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from guard_probe import Blocked, bridge_of, guard_of, open_link, preflight, verdict # noqa: E402
NATIVE_LOG = os.path.abspath(os.path.join(HERE, os.pardir, os.pardir, "ecat",
"build_native", "native_stdout.log"))
def ask(prompt):
if not sys.stdin or not hasattr(sys.stdin, "isatty") or not sys.stdin.isatty():
raise Blocked("这一步要人在场,但 stdin 不是终端:%s" % prompt)
raw = input(prompt + "(回车继续 / 输入 s 放弃本测试): ").strip().lower()
if raw == "s":
raise Blocked("用户放弃")
def ck(fails, cond, msg):
print((" OK " if cond else " FAIL ") + msg)
if not cond:
fails.append(msg)
def log_tail(n=400):
try:
with open(NATIVE_LOG, "rb") as f:
f.seek(max(0, os.path.getsize(NATIVE_LOG) - 65536))
return f.read().decode("utf-8", "replace").splitlines()[-n:]
except OSError:
return []
def wait_freeze(lk, fails, budget=12.0):
"""等"主站还在打包、出口冻住"——现场症状的定义。"""
t0 = time.time()
seen = None
while time.time() - t0 < budget:
h = lk.health()
st = lk.state()
br = bridge_of(st)
now = (br.get("tx_count"), br.get("ack"), h.get("cycles"))
if seen and seen[0] == now[0] and seen[1] == now[1] and now[2] > seen[2]:
print(" 出口已冻 %s(cycles 仍在涨 %s→%s),耗时 %.1fs"
% (now[0], seen[2], now[2], time.time() - t0))
return True
seen = now
time.sleep(0.4)
ck(fails, False, "%.0fs 内没出现「cycles 涨而出口不动」——冻结没制造成功" % budget)
return False
def run_round(lk, fails, tag, pm_hold=False):
g0 = guard_of(lk.health())
st0 = lk.state()
fires0 = g0["fires"]
wr0 = st0.get("wr", 0)
if pm_hold:
lk.api("/api/pm/hold", {"hold": 1})
print(" pm_hold=1(守卫按设计静音;心跳必须仍在涨)")
ask("请按 GD32 板上 RESET(让 APP 停止消费输出 PDO)")
if not wait_freeze(lk, fails):
ask("请让 GD32 重新跑起 APP,然后继续")
return
_r, ms = lk.send()
print(" 单次发送完成(HTTP %.1fms);开始盯 guard" % ms)
t_fire = None
armed_at = None
stale_max = 0.0
t0 = time.time()
while time.time() - t0 < 25.0:
g = guard_of(lk.health())
if g["armed"] and armed_at is None:
armed_at = time.time() - t0
stale_max = max(stale_max, g.get("tx_stale_s") or 0)
if g["fires"] > fires0:
t_fire = time.time() - t0
break
time.sleep(0.5)
hit = [l for l in log_tail() if "GUARD" in l and "全量重连" in l]
if pm_hold:
ck(fails, t_fire is None, "pm_hold 期间不得触发(%s)"
% ("没触发 ✔" if t_fire is None else "%.1fs 就触发了 ✗" % t_fire))
g1 = guard_of(lk.health())
ticks0 = g0["alive_ticks"]
time.sleep(1.2)
g2 = guard_of(lk.health())
ck(fails, g2["alive_ticks"] - g1["alive_ticks"] >= 3,
"静音中心跳仍在涨(+%d/1.2s)—— 证明是设计静音而不是线程死了"
% (g2["alive_ticks"] - g1["alive_ticks"]))
lk.api("/api/pm/hold", {"hold": 0})
ask("请让 GD32 重新跑起 APP,然后继续")
return
ck(fails, armed_at is not None and armed_at <= 1.5,
"armed 在 %.2fs 内起来(旧判据在这里就已经断了)" % (armed_at if armed_at is not None else -1))
ck(fails, stale_max >= 8.0, "tx_stale_s 爬过 8s(峰值 %.1fs)" % stale_max)
ck(fails, t_fire is not None,
"fires %d→%d:守卫在 %.1fs 时触发(预算 25s)"
% (fires0, guard_of(lk.health())["fires"], t_fire if t_fire is not None else -1))
ck(fails, bool(hit), "native_stdout.log 里有 [GUARD] … 全量重连 这一行(最近命中 %d 条)"
% len(hit))
if t_fire is None:
print(" ⇒ 判据没触发:先 curl /api/native/health 看 guard 块,"
"再确认跑的是新 exe(tools/ecat/check_build_provenance.py)")
ask("请让 GD32 重新跑起 APP(守卫已做过 close→open,恢复应该很快)")
t1 = time.time()
resumed = False
while time.time() - t1 < 30.0:
h = lk.health()
if h.get("loop") == 1 and (h.get("wkc") or 0) >= 3 and h.get("dc_cycle_us") == 250:
pre = bridge_of(lk.state())
_r, ms2 = lk.send()
lag, ok = None, False
t2 = time.perf_counter()
while time.perf_counter() - t2 < 2.0:
bb = bridge_of(lk.state())
if (bb.get("tx_count") != pre.get("tx_count")
or bb.get("ack") != pre.get("ack")):
ok, lag = True, (time.perf_counter() - t2) * 1000.0
break
time.sleep(0.002)
if ok:
resumed = True
print(" 恢复:出口重新前进(发送→跟上 %.1fms),probe HTTP %.1fms" % (lag, ms2))
break
time.sleep(0.5)
ck(fails, resumed, "松手后出口重新前进且总线回到 250us/OP")
st1 = lk.state()
ck(fails, st1.get("wr", 0) > wr0, "wr %d→%d(全量重连计一次;wr 汇总所有恢复路径,"
"不能单独当本判据的凭据)" % (wr0, st1.get("wr", 0)))
print(" 冷却观察:每 5s 一帧共 70s,期间 fires 不得再涨")
f_now = guard_of(lk.health())["fires"]
t3 = time.time()
extra = 0
while time.time() - t3 < 70.0:
lk.send()
time.sleep(5.0)
g = guard_of(lk.health())
if g["fires"] > f_now and time.time() - t3 < 60.0:
extra += 1
f_now = g["fires"]
g_end = guard_of(lk.health())
ck(fails, extra == 0 and g_end["fires"] <= fires0 + 2,
"冷却守住:本轮 fires %d→%d,60s 内额外触发 %d 次" % (fires0, g_end["fires"], extra))
def main():
fails = []
rounds_pm = "--with-pm-hold" in sys.argv
lk = open_link()
preflight(lk)
g = guard_of(lk.health())
since_full = g.get("since_full_s", -1)
if since_full is not None and since_full >= 0 and since_full < 61:
wait = 62 - since_full
print("上次全量重连在 %.0fs 前,等 %.0fs 让 60s 冷却走完" % (since_full, wait))
time.sleep(wait)
st = lk.state()
act = [m for m in (st.get("motors") or []) if m.get("online")]
if act:
print(" 注意:有 %d 个电机槽位 online。本测试会让总线断流并强制重连," % len(act))
ask("请确认电机已停止/未使能(否则输入 s 放弃)")
print("\n=== 主轮:制造冻结,要求守卫触发 ===")
run_round(lk, fails, "main")
if rounds_pm:
print("\n=== 对照轮:pm_hold=1 时同样的冻结不得触发,但心跳必须仍在涨 ===")
run_round(lk, fails, "pm_hold", pm_hold=True)
print("\n日志:%s" % NATIVE_LOG)
return verdict(not fails, "; ".join(fails[:4]))
if __name__ == "__main__":
try:
sys.exit(main())
except Blocked as e:
print("BLOCKED %s" % e)
print("VERDICT BLOCKED")
sys.exit(2)
except KeyboardInterrupt:
print("VERDICT BLOCKED 中断(别忘了 GD32 可能还在 RESET 状态)")
sys.exit(2)