Files
DCU_DesktopController/scripts/check_guard_fires_hw.py
T

210 lines
9.0 KiB
Python
Raw Normal View History

"""真机正向验证:主动制造输出冻结,证明守卫**真的**会触发,且 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)