之前 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 时判据 按设计是静音的,那种"绿了"毫无意义,所以判据只放一个执行点。
210 lines
9.0 KiB
Python
210 lines
9.0 KiB
Python
"""真机正向验证:主动制造输出冻结,证明守卫**真的**会触发,且 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)
|