fix(ui): 「谁可以动总线」收到一处判定;healthCheck 与 OTA 页不再自己 loop_stop
同一症状(点一次发送→很久才动/不动)至少有五条路径能到达,上一轮只加固了 native 检测层那一条的一半。本 commit 关掉两条由我们自己的前端反复按 loop_stop 的成因——不需要硬件故障就能把健康总线掐停:motor/start 内部先 loop_stop,停手 >1000ms 从站过程数据看门狗就踢出 OP,反复这样会把 SSC 邮箱楔到只能断电。 1) healthCheck 违反自己的契约。文件注释写着「只读…绝不 motor/start、不动总线」, 代码却是内联的 dc !== 1 || dcs !== 250 → motor/start。这把「回包没有 dc 字段」 当成「DC 配错」,于是一篇错误回包/一份旧 exe 就打出一记 loop_stop,而这条路径 只在 probe() 超时后走(那一刻已经有一记 loop_stop 在飞)。 git 史:断言383535b引入即红 →2304ed6改走 ecat-bus.repairEcatDc(含「缺字段 不动总线」守卫)转绿 →7ea80f0前端回滚删掉那次调用,又红。 2) OTA 负载页以「读数不新鲜」为理由无条件 motor/start。固件 <1.5.61(段5 恒 0)、 GD32 没跑 APP、首个 1s 窗口没滚完都会读数不新鲜而循环完全健康——一个跟电机无关 的页面于是周期性掐停总线。现在先证明再动手;native 完全没起仍算「该拉起」, 刷写体验不变。 做法:判定与预算只放 ecat-bus.js—— proveEcatLoop(h) 'up'|'down'|'unknown':三态是重点,证明不了就不许自动动手 proveEcatDc(h) 同上,把「缺字段=证明不了」收编成唯一实现 ensureEcatLoopRepair() in-flight 锁 + 最小间隔 20s(时间戳先写后发,落 localStorage 才跨标签页/跨 F5)+ 连续 3 次封顶;reason 随 body 走,供代理侧统一上限 repairEcatDc 新增「native 正在全量重连时不动总线」;isEcatBusHealthy 与 proveEcatLoop 共用同一处判定(两个「看起来在跑」迟早漂移)。transport-ecat 的 readHealth 附带原始 回包 raw,证明函数与界面读同一份,不再抄一遍字段名。 测试:test_ecat_autostart.mjs 26→57 项。stub 现在能喂任意 health 形状并按 reason 计数,新增 [2c] 反空断言(DC 确实配错时必须恰又一次且带 reason)、[2d] 三态矩阵、 [2e] OTA 决策、[2f] 预算(间隔/封顶/复位后放行)。另加 scripts/check_fe_guard_mutation.py: 把实现逐条退回旧形状看断言是否真变红(全绿=那条断言是自证的)——M1..M6 实测全部变红, 还原后基线仍 57/0。配套门槛见 tools/ecat/check_guard_regression.py 的 S6/S7。 未做(另计):loop=0 的三重闩锁(transport 的 started 位、transient 正则、device-link 的 e.loop=loadValid||op 假位——native /api/status 的 slave.state 是缓存,循环停了仍报 8) 与 UI 对 loop/guard 的可见性;以及 loop_stop 不清 g_cmd_q 造成的「重启那刻陈旧命令一起灌出去」。
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
"""变异检查:把"motor/start 许可判定"逐条退回旧形状,门槛必须变红。
|
||||
|
||||
存在理由:`node scripts/test_ecat_autostart.mjs` 全绿只说明"当前实现满足了断言",
|
||||
不说明断言有灵敏度 —— 本项目反复栽在"测试绿着但判据永不触发"(tx 冻结守卫、
|
||||
t8_soak 反极性都是同族)。这份脚本把实现改坏,看测试是否变红;只要有一条仍全绿,
|
||||
那条断言就是自证的空话,禁止当成验收凭据。
|
||||
|
||||
它会**原地改写**两个 JS 文件再逐字节还原,所以要求这两个文件在工作树里是干净的
|
||||
(有自己的未提交改动时先 commit 或 stash,别在这儿叠)。
|
||||
|
||||
用法:python scripts/check_fe_guard_mutation.py [--keep-report]
|
||||
退出码:0=每条变异都被测出(且还原后基线仍绿) 1=有空断言或还原失败 2=前置不满足
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
BUS = os.path.join(ROOT, "js", "common", "ecat-bus.js")
|
||||
TR = os.path.join(ROOT, "js", "motor", "transport-ecat.js")
|
||||
TEST = os.path.join("scripts", "test_ecat_autostart.mjs")
|
||||
FAIL_MARK = chr(10007) # ✗
|
||||
SUMMARY = "通过 (\\d+) 项,失败 (\\d+) 项"
|
||||
|
||||
# (说明, 文件, 原文, 改成的旧形状)
|
||||
MUTS = [
|
||||
("healthCheck 不再走 repairEcatDc(退回什么都不自愈)", TR,
|
||||
" await repairEcatDc(api, h.raw);", " /* MUTATED */"),
|
||||
("proveEcatDc 把缺字段当成配错(7ea80f0 内联副本的形状)", BUS,
|
||||
' if (h.dc == null && h.period_ms == null && h.dc_cycle_us == null) return "unknown";',
|
||||
" /* MUTATED */"),
|
||||
("去掉自动重启的最小间隔", BUS,
|
||||
"export const AUTO_START_MIN_INTERVAL_MS = 20000;",
|
||||
"export const AUTO_START_MIN_INTERVAL_MS = 0;"),
|
||||
("去掉「native 正在全量重连时不动总线」", BUS,
|
||||
" if (h.recovering) return null;" + chr(10) + " return ecatPost",
|
||||
" /* MUTATED */" + chr(10) + " return ecatPost"),
|
||||
("proveEcatLoop 把「读不到」当成 down(抹掉 unknown 三态)", BUS,
|
||||
' if (!h || h.ok !== true) {' + chr(10)
|
||||
+ ' return (opts && opts.treatOfflineAsDown) ? "down" : "unknown";' + chr(10) + ' }',
|
||||
' if (!h || h.ok !== true) return "down";'),
|
||||
("去掉连续封顶(AUTO_START_MAX_STREAK 无限大)", BUS,
|
||||
"export const AUTO_START_MAX_STREAK = 3;",
|
||||
"export const AUTO_START_MAX_STREAK = 9999;"),
|
||||
]
|
||||
|
||||
|
||||
def run_suite():
|
||||
p = subprocess.run(["node", TEST], cwd=ROOT, capture_output=True, text=True,
|
||||
encoding="utf-8", errors="replace", timeout=300)
|
||||
out = p.stdout or ""
|
||||
m = re.search(SUMMARY, out)
|
||||
reds = [l.split(FAIL_MARK)[1].strip()[:44] for l in out.splitlines() if FAIL_MARK in l]
|
||||
if not m:
|
||||
why = (out.strip().splitlines() or [""])[-1] or (p.stderr or "").strip()[:80]
|
||||
return (-1, 1), ["(无汇总行,进程提前退出)" + why]
|
||||
return (int(m.group(1)), int(m.group(2))), reds
|
||||
|
||||
|
||||
def dirty(paths):
|
||||
try:
|
||||
r = subprocess.run(["git", "status", "--porcelain", "--"] + paths,
|
||||
cwd=ROOT, capture_output=True, text=True, timeout=20)
|
||||
return bool((r.stdout or "").strip())
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
if dirty([os.path.relpath(BUS, ROOT), os.path.relpath(TR, ROOT)]):
|
||||
print("BLOCKED 目标文件有未提交改动,变异脚本不做叠写;先 commit 或 stash")
|
||||
print("VERDICT BLOCKED")
|
||||
return 2
|
||||
bak = {f: open(f, "rb").read() for f in (BUS, TR)}
|
||||
|
||||
def restore():
|
||||
for f, b in bak.items():
|
||||
open(f, "wb").write(b)
|
||||
for f, b in bak.items():
|
||||
assert open(f, "rb").read() == b, "还原失败: " + f
|
||||
|
||||
base, _ = run_suite()
|
||||
print("基线:通过 %d 失败 %d" % base)
|
||||
if base[1] != 0 or base[0] < 0:
|
||||
print("VERDICT FAIL 基线就没绿,变异检查无从谈起")
|
||||
return 1
|
||||
vacuous = []
|
||||
for name, path, old, new in MUTS:
|
||||
s = open(path, "rb").read().decode("utf-8")
|
||||
if s.count(old) != 1:
|
||||
vacuous.append(name + "(锚点命中 %d 次:实现已漂移,先更新本脚本)" % s.count(old))
|
||||
print("%-56s 锚点失效" % name)
|
||||
continue
|
||||
open(path, "wb").write(s.replace(old, new, 1).encode("utf-8"))
|
||||
try:
|
||||
(np, nf), reds = run_suite()
|
||||
finally:
|
||||
restore()
|
||||
hit = nf > 0
|
||||
print("%-56s 通过 %2d 失败 %2d %s" % (name, np, nf, "变红 OK" if hit else "仍全绿 = 空断言"))
|
||||
for r in reds[:3]:
|
||||
print(" 红:", r)
|
||||
if not hit:
|
||||
vacuous.append(name)
|
||||
again, _ = run_suite()
|
||||
print("还原后复核:通过 %d 失败 %d" % again)
|
||||
if vacuous or again[1] != 0:
|
||||
print("VERDICT FAIL vacuous=%d" % len(vacuous))
|
||||
for v in vacuous:
|
||||
print(" -", v)
|
||||
return 1
|
||||
print("VERDICT PASS mutations=%d" % len(MUTS))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+147
-12
@@ -29,6 +29,11 @@ globalThis.localStorage = {
|
||||
};
|
||||
|
||||
const { createEcatTransport } = await import("../js/motor/transport-ecat.js");
|
||||
/* 总线许可判定与自动重启预算的唯一实现(与浏览器里同一份代码,不是复制品) */
|
||||
const ebus = await import("../js/common/ecat-bus.js");
|
||||
/* OTA 负载页在浏览器里会摸 window/localStorage;Node 里给最小壳,只为 import 不炸 */
|
||||
globalThis.window = globalThis.window || { addLog: () => {} };
|
||||
const { decidePdoCycle } = await import("../js/ota/ecat_load.js");
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const out = [];
|
||||
@@ -40,19 +45,22 @@ function section(t) { out.push(`\n${t}`); }
|
||||
|
||||
/* ── stub 后端:模拟 ecat_native 的状态机 ──
|
||||
* opened=false 时 /api/status 回 {ok:false,log:["未连接"]}(真实行为),
|
||||
* /api/open 之后才变 ok:true;/api/motor/start 记录被调用次数。
|
||||
* /api/native/health:bus_open/loop 跟 started 联动,供「已健康则跳过 start」快路径。 */
|
||||
* /api/open 之后才变 ok:true;/api/motor/start 记录被调用次数与 body.reason。
|
||||
* /api/native/health:bus_open/loop 跟 started 联动,供「已健康则跳过 start」快路径。
|
||||
* opts.health = 额外/覆盖的 health 字段(证明类判据要能喂各种形状,
|
||||
* 包括"没有 dc 字段的旧 exe"和 recovering=1)
|
||||
* opts.stayDown= motor/start 不把 started 置真:用来测"重启没救活"的连续封顶 */
|
||||
function makeStub(opts) {
|
||||
const st = { opened: false, started: false, calls: [], openSubstr: null,
|
||||
failOpen: opts && opts.failOpen, failStart: opts && opts.failStart,
|
||||
healthFail: opts && opts.healthFail };
|
||||
const o = opts || {};
|
||||
const st = { opened: false, started: false, calls: [], startReasons: [], openSubstr: null,
|
||||
failOpen: o.failOpen, failStart: o.failStart, healthFail: o.healthFail };
|
||||
const srv = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (c) => (body += c));
|
||||
req.on("end", () => {
|
||||
st.calls.push(req.url);
|
||||
const send = (o) => {
|
||||
const b = Buffer.from(JSON.stringify(o), "utf8");
|
||||
const send = (obj) => {
|
||||
const b = Buffer.from(JSON.stringify(obj), "utf8");
|
||||
res.writeHead(200, { "Content-Type": "application/json", "Content-Length": b.length });
|
||||
res.end(b);
|
||||
};
|
||||
@@ -66,12 +74,11 @@ function makeStub(opts) {
|
||||
}
|
||||
if (req.url === "/ecat/api/native/health") {
|
||||
if (st.healthFail) return send({ ok: false, log: ["health down"] });
|
||||
return send(st.opened
|
||||
? { ok: true, name: "ecat_native", bus_open: 1,
|
||||
if (!st.opened) return send({ ok: false, log: ["未连接"] });
|
||||
return send(Object.assign({ ok: true, name: "ecat_native", bus_open: 1,
|
||||
op: st.started ? 1 : 0, loop: st.started ? 1 : 0,
|
||||
slaves: 1, wkc: st.started ? 3 : 0,
|
||||
cycles: st.started ? 10 : 0, queue_depth: 0 }
|
||||
: { ok: false, log: ["未连接"] });
|
||||
cycles: st.started ? 10 : 0, queue_depth: 0 }, o.health || {}));
|
||||
}
|
||||
if (req.url === "/ecat/api/open") {
|
||||
if (st.failOpen) return send({ ok: false, log: [st.failOpen] });
|
||||
@@ -82,7 +89,8 @@ function makeStub(opts) {
|
||||
if (req.url === "/ecat/api/close") { st.opened = false; st.started = false; return send({ ok: true, log: [] }); }
|
||||
if (req.url === "/ecat/api/motor/start") {
|
||||
if (st.failStart) return send({ ok: false, log: [st.failStart] });
|
||||
st.started = true;
|
||||
st.startReasons.push(p.reason === undefined ? "<无 reason>" : String(p.reason));
|
||||
if (!o.stayDown) st.started = true;
|
||||
return send({ ok: true, log: [], period_ms: p.period_ms || 4, spin: true });
|
||||
}
|
||||
if (req.url === "/ecat/api/motor/state") {
|
||||
@@ -175,6 +183,133 @@ section("[2b] healthCheck:总线已健康时可确认连上,且不碰 motor/
|
||||
err !== null, err && err.message);
|
||||
}
|
||||
|
||||
/* ── 2c. DC 自愈的三条反空断言 ──
|
||||
* 少了这三条,上面"healthCheck 不调用 motor/start"可能只是因为 stub 从不给 dc 字段、
|
||||
* 判据永远为假 —— 那它就是空断言(本项目反复踩过的坑)。 */
|
||||
section("[2c] DC 自愈:证明配错才动总线,证明不了/正在恢复都不动");
|
||||
{
|
||||
const run = async (healthOverride) => {
|
||||
const { st, srv, port } = await makeStub({ health: healthOverride });
|
||||
const un = bindFetch(port);
|
||||
const tp = createEcatTransport(CTX);
|
||||
await tp.probe();
|
||||
const before = st.calls.filter((c) => c === "/ecat/api/motor/start").length;
|
||||
await tp.healthCheck();
|
||||
const after = st.calls.filter((c) => c === "/ecat/api/motor/start").length;
|
||||
un(); srv.close();
|
||||
return { delta: after - before, reasons: st.startReasons };
|
||||
};
|
||||
const bad = await run({ dc: 0, dc_cycle_us: 0, period_ms: 4 });
|
||||
check("DC 被证明配错 ⇒ 恰又多一次 motor/start(拉回 250µs)",
|
||||
bad.delta === 1, `多 ${bad.delta} 次`);
|
||||
check("DC 自愈带 reason=auto_dc_repair(代理侧据此统一节流)",
|
||||
bad.reasons.some((r) => r === "auto_dc_repair"), bad.reasons.join(","));
|
||||
const good = await run({ dc: 1, dc_cycle_us: 250, period_ms: 0.25 });
|
||||
check("DC 正确 ⇒ 零次 motor/start", good.delta === 0, `多 ${good.delta} 次`);
|
||||
const busy = await run({ dc: 0, dc_cycle_us: 0, period_ms: 4, recovering: 1 });
|
||||
check("native 正在全量重连 ⇒ 即使 DC 配错也不插第二刀", busy.delta === 0, `多 ${busy.delta} 次`);
|
||||
const blind = await run({});
|
||||
check("回包没有 dc 字段(旧 exe)⇒ 证明不了,零次(这条就是 7ea80f0 丢掉的守卫)",
|
||||
blind.delta === 0, `多 ${blind.delta} 次`);
|
||||
}
|
||||
|
||||
/* ── 2d. 证明函数本体(纯函数矩阵,不碰网络)── */
|
||||
section("[2d] proveEcatLoop / proveEcatDc 三态矩阵");
|
||||
{
|
||||
const cases = [
|
||||
["读不到 ⇒ unknown(自动化禁止动手)", null, "unknown"],
|
||||
["ok:false ⇒ unknown", { ok: false }, "unknown"],
|
||||
["缺 loop 字段 ⇒ unknown", { ok: true, bus_open: 1 }, "unknown"],
|
||||
["缺 bus_open 字段 ⇒ unknown", { ok: true, loop: 1 }, "unknown"],
|
||||
["网卡没开 ⇒ down", { ok: true, bus_open: 0, loop: 0 }, "down"],
|
||||
["开着但循环停了 ⇒ down", { ok: true, bus_open: 1, loop: 0 }, "down"],
|
||||
["在跑 ⇒ up", { ok: true, bus_open: 1, loop: 1 }, "up"],
|
||||
["正在重拉 ⇒ unknown(绿灯此刻不作数)",
|
||||
{ ok: true, bus_open: 1, loop: 1, recovering: 1 }, "unknown"],
|
||||
];
|
||||
for (const [name, h, want] of cases) {
|
||||
check(name, ebus.proveEcatLoop(h) === want, `实际 ${ebus.proveEcatLoop(h)}`);
|
||||
}
|
||||
check("连不上对「负责拉起总线」的页面算 down(treatOfflineAsDown)",
|
||||
ebus.proveEcatLoop(null, { treatOfflineAsDown: true }) === "down");
|
||||
check("同一份回包对电机页仍算 unknown(不许它据此重启)",
|
||||
ebus.proveEcatLoop(null) === "unknown");
|
||||
check("proveEcatDc: 缺字段 ⇒ unknown",
|
||||
ebus.proveEcatDc({ ok: true }) === "unknown");
|
||||
check("proveEcatDc: dc=1/250/0.25 ⇒ ok",
|
||||
ebus.proveEcatDc({ ok: true, dc: 1, dc_cycle_us: 250, period_ms: 0.25 }) === "ok");
|
||||
check("proveEcatDc: period 掉到 4ms ⇒ misconfigured",
|
||||
ebus.proveEcatDc({ ok: true, dc: 1, dc_cycle_us: 4000, period_ms: 4 }) === "misconfigured");
|
||||
}
|
||||
|
||||
/* ── 2e. OTA 负载页的决策:读数不新鲜 ≠ 循环停了 ── */
|
||||
section("[2e] OTA 页只在证明循环停了才重启(固件 <1.5.61 不再周期性掐总线)");
|
||||
{
|
||||
const cases = [
|
||||
["loop=1 但 valid 永远 false(固件段5 不支持)⇒ 绝不动总线",
|
||||
{ ok: true, bus_open: 1, loop: 1 }, "idle-up"],
|
||||
["loop=1 且 health 正常,age_ms 过期 ⇒ 仍不动总线(由 loop 位判,不看读数)",
|
||||
{ ok: true, bus_open: 1, loop: 1 }, "idle-up"],
|
||||
["loop=0 ⇒ 该重启", { ok: true, bus_open: 1, loop: 0 }, "repair"],
|
||||
["native 完全没起 ⇒ 该拉起(保住「不必先开电机页」的刷写体验)", null, "repair"],
|
||||
["正在重拉 ⇒ 不动", { ok: true, bus_open: 1, loop: 1, recovering: 1 }, "unproven"],
|
||||
];
|
||||
for (const [name, h, want] of cases) {
|
||||
check(name, decidePdoCycle(h) === want, `实际 ${decidePdoCycle(h)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 2f. 自动重启的预算:证明 + 间隔 + 连续封顶 + 复位 ── */
|
||||
section("[2f] ensureEcatLoopRepair 的预算(少一条就会变成重连风暴)");
|
||||
{
|
||||
const post = (path, body, ms) => fetch(`/ecat/api/${path}`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body || {}), signal: AbortSignal.timeout(ms || 5000),
|
||||
}).then((r) => r.json());
|
||||
const { st, srv, port } = await makeStub({ stayDown: true });
|
||||
const un = bindFetch(port);
|
||||
const age = () => _ls.set("dcu_ecat_auto_start_at", String(Date.now() - 30000));
|
||||
|
||||
let r0 = await ebus.ensureEcatLoopRepair(post, "auto_loop_repair");
|
||||
check("网卡没开且没给 treatOfflineAsDown ⇒ 证明不了,不动总线",
|
||||
r0.acted === false && r0.why === "unproven", r0.why);
|
||||
|
||||
await post("open", { substr: "I210" }); /* 现在 health: bus_open=1, loop=0 */
|
||||
let r1 = await ebus.ensureEcatLoopRepair(post, "auto_loop_repair",
|
||||
{ treatOfflineAsDown: false });
|
||||
check("证明 loop=0 ⇒ 恰一次 motor/start", r1.acted === true, JSON.stringify(r1));
|
||||
check("自动重启带 reason=auto_loop_repair",
|
||||
st.startReasons[st.startReasons.length - 1] === "auto_loop_repair",
|
||||
st.startReasons.join(","));
|
||||
let r2 = await ebus.ensureEcatLoopRepair(post, "auto_loop_repair");
|
||||
check("间隔内第二次被拒(时间戳先写后发,并发才不会叠加)",
|
||||
r2.acted === false && r2.why === "interval", JSON.stringify(r2));
|
||||
let capped = [];
|
||||
/* 先把预算复位到 0,否则上面那一次 act 会被算进连续计数里,
|
||||
* 断言就变成了"总共最多 3 次"而不是"连续 3 次没救活就封顶"。 */
|
||||
st.started = true;
|
||||
await ebus.ensureEcatLoopRepair(post, "auto_loop_repair");
|
||||
st.started = false;
|
||||
age();
|
||||
for (let i = 0; i < 4; i++) { capped.push(await ebus.ensureEcatLoopRepair(post, "auto_loop_repair")); age(); }
|
||||
check("连续 3 次没救活 ⇒ 第 4 次封顶不再动总线(深楔死要的是断电)",
|
||||
capped[2].acted === true && capped[3].acted === false && capped[3].why === "capped",
|
||||
capped.map((c) => (c.acted ? "act" : c.why)).join(","));
|
||||
st.started = true; /* 总线自己活过来了 */
|
||||
let rUp = await ebus.ensureEcatLoopRepair(post, "auto_loop_repair");
|
||||
check("看到 loop=1 ⇒ 不重启并复位预算", rUp.acted === false && rUp.why === "not_down",
|
||||
JSON.stringify(rUp));
|
||||
age();
|
||||
st.started = false;
|
||||
let r3 = await ebus.ensureEcatLoopRepair(post, "auto_loop_repair");
|
||||
check("复位后重新故障 ⇒ 还能再试一次(封顶不是一刀切死)", r3.acted === true,
|
||||
JSON.stringify(r3));
|
||||
check("预算状态可被界面读到(横幅要说 n/3)",
|
||||
typeof ebus.autoLoopState().streak === "number"
|
||||
&& ebus.autoLoopState().inFlight === false, JSON.stringify(ebus.autoLoopState()));
|
||||
un(); srv.close();
|
||||
}
|
||||
|
||||
/* ── 3. 网卡打不开 → 报错要冒泡且**不**标 transient(否则页面一直转圈不给红灯)── */
|
||||
section("[3] 打不开网卡时的错误语义");
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user