From 514070b36ddef67b8dc88409a13e5cc014d8cbe5 Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Tue, 22 Sep 2026 00:30:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=E3=80=8C=E8=B0=81=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E5=8A=A8=E6=80=BB=E7=BA=BF=E3=80=8D=E6=94=B6=E5=88=B0=E4=B8=80?= =?UTF-8?q?=E5=A4=84=E5=88=A4=E5=AE=9A=EF=BC=9BhealthCheck=20=E4=B8=8E=20O?= =?UTF-8?q?TA=20=E9=A1=B5=E4=B8=8D=E5=86=8D=E8=87=AA=E5=B7=B1=20loop=5Fsto?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同一症状(点一次发送→很久才动/不动)至少有五条路径能到达,上一轮只加固了 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 造成的「重启那刻陈旧命令一起灌出去」。 --- js/common/ecat-bus.js | 168 +++++++++++++++++++++++++---- js/motor/transport-ecat.js | 26 +++-- js/ota/ecat_load.js | 127 ++++++++++++---------- scripts/check_fe_guard_mutation.py | 118 ++++++++++++++++++++ scripts/test_ecat_autostart.mjs | 159 ++++++++++++++++++++++++--- 5 files changed, 501 insertions(+), 97 deletions(-) create mode 100644 scripts/check_fe_guard_mutation.py diff --git a/js/common/ecat-bus.js b/js/common/ecat-bus.js index 3f2dbd2..ec99344 100644 --- a/js/common/ecat-bus.js +++ b/js/common/ecat-bus.js @@ -9,6 +9,12 @@ * - DC 钉死 250µs(period_ms 默认/钳制 0.25,dc_cycle_us = max(250, round(ms*1000))) * - 幂等:native/health 报 bus_open+loop 时**复用**,不再 motor/start * (start 会先 loop_stop,热路径假超时 / SSC 楔死风险) + * - **自动化只能基于证明,人才能基于意图**:任何非用户直接触发的 motor/start + * 都必须先过 proveEcatLoop / proveEcatDc,证明"确实需要动"才动;证明不了 + * (!ok / 缺字段 / 正在恢复)一律不动总线。合法 reason 只有四个: + * user_probe / user_period / auto_loop_repair / auto_dc_repair。 + * 为什么这么严:motor/start 内部先 loop_stop,停循环 >1000ms 就从站过程数据 + * 看门狗踢出 OP;反复这样会把 SSC 邮箱楔到只能断电(历史多次踩过)。 * - 网卡默认常量 ECAT_DEFAULT_ADAPTER 只在此定义 */ "use strict"; @@ -81,11 +87,125 @@ export function readStoredEcatPeriod() { } } -/** @description 只读健康:bus_open + loop = 后台 PDO 循环在跑。 */ +/** + * 「后台 PDO 循环到底在不在跑」的唯一判据。纯函数:不 fetch、不读 localStorage、 + * 不动任何状态 —— 所以能被 test_ecat_autostart.mjs 离线逐例判死。 + * + * 为什么需要第三态 'unknown':只有 up/down 两值时,"读不到/字段缺失"必然落到 + * 其中一边,落到 down 就等于"仪表坏了就重启总线"。历史上 healthCheck 的内联判据 + * 正是这个形状(把"没有 dc 字段"当成"DC 配错"),于是一篇错误回包就打出一记 + * loop_stop。unknown 一律禁止自动化动手。 + * + * @param {any} h native/health 的回包(或 null=请求失败) + * @param {{treatOfflineAsDown?:boolean}} [opts] 仅给"本来就要负责把总线拉起来"的页面 + * (OTA 负载页)用:连不上/未开网卡 ⇒ 没有任何健康循环会被我们停掉,开它是安全的。 + * @returns {"up"|"down"|"unknown"} + */ +export function proveEcatLoop(h, opts) { + if (!h || h.ok !== true) { + return (opts && opts.treatOfflineAsDown) ? "down" : "unknown"; + } + if (h.bus_open == null || h.loop == null) return "unknown"; + if (h.recovering) return "unknown"; /* native 正在全量重连:再插一记 start 是自相残杀 */ + if (!h.bus_open) return "down"; /* 网卡都没开,不存在被我们停掉的健康循环 */ + return h.loop ? "up" : "down"; +} + +/** + * 「DC/周期是否被*证明*配错」。缺字段 = 证明不了 = 不动总线(repairEcatDc 旧规则收编于此)。 + * @returns {"ok"|"misconfigured"|"unknown"} + */ +export function proveEcatDc(h) { + if (!h || h.ok !== true) return "unknown"; + if (h.dc == null && h.period_ms == null && h.dc_cycle_us == null) return "unknown"; + if (h.dc === 1 && Number(h.dc_cycle_us) === ECAT_DC_CYCLE_US && + (h.period_ms == null || Math.abs(Number(h.period_ms) - ECAT_DEFAULT_PERIOD_MS) < 0.001)) { + return "ok"; + } + return "misconfigured"; +} + +/** + * 自动重启的预算。三条都要,缺一条就会从"修楔死"变成"造楔死": + * in-flight —— 同一上下文内不并发(一次 motor/start 最长 30s,足够叠好几记) + * interval —— 最小间隔,时间戳**先写再发**;落 localStorage 才能跨标签页/跨 F5 + * (tab-keepalive 下每页 iframe 常驻,上下文不止一个) + * streak —— 连续 N 次没救活就别再试了(深楔死要的是断电,不是第 N+1 次重连) + */ +const LS_AUTO_START_AT = "dcu_ecat_auto_start_at"; +export const AUTO_START_MIN_INTERVAL_MS = 20000; +export const AUTO_START_MAX_STREAK = 3; +const autoLoop = { inFlight: false, streak: 0, lastWhy: "" }; + +/** 谁看到 loop 真的在跑,谁把 streak 清零(不要求是发起重启的那个人)。 */ +export function noteEcatLoopUp() { + autoLoop.streak = 0; + autoLoop.lastWhy = ""; +} + +/** @returns {{inFlight:boolean, streak:number, lastWhy:string}} 供界面/测试读 */ +export function autoLoopState() { + return { inFlight: autoLoop.inFlight, streak: autoLoop.streak, lastWhy: autoLoop.lastWhy }; +} + +/** + * 自动重启 PDO 循环的**唯一**入口。自己重新证明一次(不信调用方的分类), + * 证明是 down 且预算允许,才走 open + motor/start。 + * + * @param {(path:string,body?:object,timeoutMs?:number)=>Promise} ecatPost + * @param {string} reason 记进 body,供代理侧统一计数/上限(B4)与人看日志 + * @param {{treatOfflineAsDown?:boolean, adapter?:string, fromLocalStorage?:boolean}} [opts] + * @returns {Promise<{acted:boolean, why:string}>} + */ +export async function ensureEcatLoopRepair(ecatPost, reason, opts) { + opts = opts || {}; + if (autoLoop.inFlight) return { acted: false, why: "in_flight" }; + var h = null; + try { + h = await ecatPost("native/health", {}, 2500); + } catch (_) { + h = null; + } + if (proveEcatLoop(h, opts) === "up") { + noteEcatLoopUp(); + return { acted: false, why: "not_down" }; + } + if (proveEcatLoop(h, opts) !== "down") return { acted: false, why: "unproven" }; + if (autoLoop.streak >= AUTO_START_MAX_STREAK) return { acted: false, why: "capped" }; + var now = Date.now(), last = 0; + try { + last = Number(localStorage.getItem(LS_AUTO_START_AT)) || 0; + } catch (_) {} + if (now - last < AUTO_START_MIN_INTERVAL_MS) return { acted: false, why: "interval" }; + try { + localStorage.setItem(LS_AUTO_START_AT, String(now)); /* 先占额度再发:并发才不会叠加 */ + } catch (_) {} + autoLoop.inFlight = true; + autoLoop.streak++; + try { + await runEcatBootstrap({ + ecatPost: ecatPost, + adapter: opts.adapter, + fromLocalStorage: opts.fromLocalStorage !== false, + reason: reason || "auto_loop_repair", + }); + autoLoop.lastWhy = "ok"; + return { acted: true, why: reason || "auto_loop_repair" }; + } catch (e) { + autoLoop.lastWhy = (e && e.message) || "failed"; + throw e; + } finally { + autoLoop.inFlight = false; + } +} + +/** + * @description 只读健康:bus_open + loop = 后台 PDO 循环在跑。判据与 proveEcatLoop + * 同一处实现(两个"看起来在跑"的定义迟早会漂移,而这里为真时上面那条必须为真)。 + */ export async function isEcatBusHealthy(ecatPost) { try { - var h = await ecatPost("native/health", {}, 2500); - return !!(h && h.ok && h.bus_open && h.loop); + return proveEcatLoop(await ecatPost("native/health", {}, 2500)) === "up"; } catch (_) { return false; } @@ -126,7 +246,10 @@ export async function startEcatMotorLoop(ecatPost, opts) { } else { body = resolveEcatStartBody({ period_ms: opts.period_ms, spin: opts.spin }); } - var r = await ecatPost("motor/start", body, 30000); + var r = await ecatPost("motor/start", Object.assign({}, body, + /* reason 随 body 走:代理侧据此对"自动修复类"重启统一节流上限(B4), + * 页面重写一版也不会漏 —— 用户自己点的那一记永不拦。 */ + opts.reason ? { reason: String(opts.reason) } : {}), 30000); if (r && r.ok === false) { throw new Error((r.log && r.log.join(" / ")) || "motor/start 失败"); } @@ -138,7 +261,7 @@ export async function startEcatMotorLoop(ecatPost, opts) { * @param {{ * ecatPost: (path:string,body?:object,timeoutMs?:number)=>Promise, * adapter?: string, period_ms?: number, spin?: boolean|null, force?: boolean, - * fromLocalStorage?: boolean, + * fromLocalStorage?: boolean, reason?: string, * }} opts * @returns {Promise<{ok:true, reused:boolean, result?:object}>} */ @@ -154,30 +277,39 @@ export async function runEcatBootstrap(opts) { spin: opts.spin, fromLocalStorage: opts.fromLocalStorage, body: opts.body, + reason: opts.reason, }); return { ok: true, reused: false, result: st.result }; } /** - * DC/周期自愈:被配错(free-run 或 ≥1ms)时拉回 250µs。 - * health 未回报 dc/period 字段时**不动总线**(healthCheck 契约:只读)。 - * @returns {Promise} null=无需修复 + * DC/周期自愈:被**证明**配错(free-run 或 ≥1ms)时拉回 250µs。 + * 判据在 proveEcatDc 里(唯一实现):证明不了 = 不动总线。这条规则的存在理由就是 + * healthCheck 那份内联副本 —— 它把"没有 dc 字段"当成"DC 配错",于是一篇错误回包 + * 就打出一记 loop_stop 的 motor/start(记忆 ecat-bus-exclusive-route-restart-trips-watchdog)。 + * @param {(path:string,body?:object,timeoutMs?:number)=>Promise} ecatPost + * @param {object} [knownHealth] 调用方已经读到的**原始** native/health 回包(省一次往返, + * 判据仍只走 proveEcatDc 这一处)。传错形状会被判成 unknown —— 也就是不动总线。 + * @returns {Promise} null=无需修复或无法证明 */ -export async function repairEcatDc(ecatPost) { - var h = await ecatPost("native/health", {}, 2000).catch(function () { return null; }); - if (!h || !h.ok) return null; - /* 缺关键字段 = 无法证明配错,禁止 motor/start(healthCheck 要求不碰 start) */ - if (h.dc == null && h.period_ms == null && h.dc_cycle_us == null) return null; - var dcs = Number(h.dc_cycle_us); - var per = Number(h.period_ms); - if (h.dc === 1 && dcs === ECAT_DC_CYCLE_US && - (h.period_ms == null || Math.abs(per - ECAT_DEFAULT_PERIOD_MS) < 0.001)) { - return null; +export async function repairEcatDc(ecatPost, knownHealth) { + var h = knownHealth === undefined ? null : knownHealth; + if (h === null) { + try { + h = await ecatPost("native/health", {}, 2000); + } catch (_) { + h = null; + } } + if (proveEcatDc(h) !== "misconfigured") return null; + /* native 正在全量重连(close→open→OP,秒级)时再插一记 motor/start 是自相残杀: + * 两条恢复路各自 loop_stop,最后谁都不知道总线是谁停的。 */ + if (h.recovering) return null; return ecatPost("motor/start", { period_ms: ECAT_DEFAULT_PERIOD_MS, dc_cycle_us: ECAT_DC_CYCLE_US, dc_shift_us: 0, + reason: "auto_dc_repair", }, 30000); } diff --git a/js/motor/transport-ecat.js b/js/motor/transport-ecat.js index 5ac3744..fb8599f 100644 --- a/js/motor/transport-ecat.js +++ b/js/motor/transport-ecat.js @@ -42,6 +42,9 @@ * 「ECAT 槽判活阈值」一节,纯函数以便脱离 DOM 自测。 */ import { staleLimitMs } from "./refresh-rate.js"; import { deviceLink } from "../common/device-link.js"; +/* 总线"能不能动"的判据只有一份,放在 ecat-bus.js(与 device-link 同源、同样不带 + * ?v= ⇒ 浏览器按同一 URL 去重,Owner 与本模块共用同一个模块实例与同一份预算)。 */ +import { proveEcatLoop, repairEcatDc, noteEcatLoopUp } from "../common/ecat-bus.js"; import { desktopEcatPost, desktopBridgeLabel, getDesktopEcatApi, desktopEcatSendRaw, hasDesktopFastSend, @@ -334,14 +337,19 @@ export function createEcatTransport(ctx) { op: !!h.op, loop: !!h.loop, cycles: Number(h.cycles) || 0, + /* 原始回包:证明函数与界面要读 dc/period_ms/guard/recovering/pm_hold 这些 + * 只存在于 native 侧的字段。再抄一遍字段名 = 迟早与 native 漂移, + * 而漂移的方向通常是"页面以为一切正常"。 */ + raw: h, }; } - /** 总线已开 + PDO 循环在跑 → 视为链路就绪,跳过 motor/start。 */ + /** 总线已开 + PDO 循环在跑 → 视为链路就绪,跳过 motor/start。 + * 判据是 ecat-bus.proveEcatLoop 的 'up'(三态里只有 up 算健康)。 */ async function isBusHealthy() { try { const h = await readHealth(); - return !!(h && h.busOpen && h.loop); + return proveEcatLoop(h && h.raw) === "up"; } catch (_) { return false; } @@ -614,20 +622,18 @@ export function createEcatTransport(ctx) { const h = await readHealth(); if (!h || !h.busOpen) throw new Error("EtherCAT 服务未打开网卡"); if (!h.loop) throw new Error("PDO 循环未运行"); - /* 自愈:DC/周期被配错(free-run 或 ≥1ms)时发送出口会到秒级 —— 拉回 250µs */ + /* DC 自愈只能走 ecat-bus.repairEcatDc:它"证明不了配错就不动总线"。 + * 以前这里是内联的 dc !== 1 || dcs !== 250 —— 把"回包没有 dc 字段"当成 + * "DC 配错",于是一篇错误回包/一份旧 exe 就打出一记 loop_stop 的 + * motor/start,而这条路径恰好只在 probe() 超时(已有一记 loop_stop 在飞)后走。 */ try { - const raw = await api("native/health", {}, 2000); - const dc = raw && raw.dc; - const dcs = raw && raw.dc_cycle_us; - const per = raw && raw.period_ms; - if (dc !== 1 || dcs !== 250 || (per != null && Math.abs(per - 0.25) > 0.001)) { - await api("motor/start", { period_ms: 0.25, dc_cycle_us: 250, dc_shift_us: 0 }, 30000); - } + await repairEcatDc(api, h.raw); } catch (_) { /* 自愈失败仍按 health 结果返回 */ } const st = await api("status", {}, 2500); if (!st || st.ok !== true) throw new Error("从站状态不可读"); if (st && typeof st === "object") st.__op = !!h.op; started = true; + noteEcatLoopUp(); /* 走到这里 health 已证明 loop=1:把自动重启预算交还给下一次故障 */ try { const snap = await api("motor/state", {}, 2500); if (snap && snap.ok === true) syncFromSnapshot(snap); diff --git a/js/ota/ecat_load.js b/js/ota/ecat_load.js index 4b2cd68..f0ee5f2 100644 --- a/js/ota/ecat_load.js +++ b/js/ota/ecat_load.js @@ -10,12 +10,17 @@ * 与 /system/status 轮询完全独立:ESP32 离线不影响本显示,反之亦然; * 失败只静默退避,绝不计入 ESP32 的连接失败计数。 * - * **自动拉起**(2026-09):读到 invalid/过期时,代替用户走完 + * **自动拉起**(2026-09-21 起改为"先证明再动"):只有证明"后台 PDO 循环确实停了" + * 才代替用户走完 * 服务→网卡→OP→PDO 循环 四级自愈(与 motor/transport-ecat.js 同一套 API), - * 不必再单独打开电机页选 EtherCAT。避让: + * 不必再单独打开电机页选 EtherCAT。为什么必须改:'读数不新鲜' 与 '循环停了' 不是一回事 + * —— 固件 <1.5.61(段 5 恒 0)、app 没跑、首个 1s 窗口没滚完,都会读数不新鲜而循环 + * 完全健康;旧实现按读数不新鲜就 motor/start,而 motor/start 先 loop_stop,于是这个 + * 跟电机无关的页面会周期性掐停健康总线(停手 >1s 看门狗踢出 OP,反复会把邮箱楔到只能断电)。 + * 避让: * - ECAT 刷写中(window.bootEcatBusy)或其它 OTA 在跑时**绝不动总线** * —— open 会 loop_stop、motor/start 会抢 OP,会把正在灌写的会话掐死; - * - 冷却 8s,避免无适配器/无从站时每秒刷 open; + * - 冷却 8s(本页)+ 自动重启全局预算:最小间隔 20s、连续 3 次没救活就封顶(ecat-bus); * - 首次成功只打一条日志,之后静默。 * 首页需持有 app 租约(index.html 挂 app-lease.js),否则服务端在 * 租约归零宽限期后会把刚拉起的循环又停掉。 @@ -31,7 +36,9 @@ * 请求统一走 ConnectionHub(device-link.ecatPost),与 Owner 健康轮询同源。 */ import { deviceLink } from '../common/device-link.js'; -import { ECAT_DEFAULT_ADAPTER } from '../common/ecat-bus.js'; +import { + proveEcatLoop, ensureEcatLoopRepair, noteEcatLoopUp, +} from '../common/ecat-bus.js'; var POLL_MS = 1000; /* 正常轮询节拍(负载窗口 1s 一滚,再快没意义) */ @@ -57,8 +64,9 @@ function setTitle(node, title) { } var TIP_BASE = 'DCU_APP CPU 负载率:中断+非延迟处理(任务派发/OTA中继)占墙钟比,不含常开轮询与空转;EtherCAT 输入 PDO 段5,1s 窗口'; -var TIP_OFF = TIP_BASE + ';当前不可用:外壳/本页会自动 open+motor/start;' - + '若持续为 --,检查网卡/总线供电,或手动打开电机控制页选 EtherCAT。' +var TIP_OFF = TIP_BASE + ';当前不可用:只有证明 PDO 循环确实停了,本页才会自动 open+motor/start;' + + '循环在跑而这里仍是 --,通常是固件 <1.5.61(段5 恒 0)、GD32 没跑 APP,' + + '或刚拉起还没滚完首个 1s 窗口 —— 这些都不会去重启总线。' + 'ECAT 刷写期间会暂停自动拉起。'; /** @description 把 /api/app/load 的 JSON 渲染到 #mGd32Load。返回值是否有效(驱动退避)。 */ @@ -98,15 +106,6 @@ function ecatPost(path, body, timeoutMs) { }).then(function (r) { return r.json(); }); } -function adapterSubstr() { - try { - var s = localStorage.getItem('dcu_ecat_adapter'); - if (s && s.trim()) return s.trim(); - } catch (_) {} - return ECAT_DEFAULT_ADAPTER; - -} - /** 刷写/上传进行中:绝不动总线(open=loop_stop,start=抢 OP)。 */ function busBusy() { return !!window.bootEcatBusy || !!window.isUploading @@ -114,54 +113,64 @@ function busBusy() { } /** - * 确保网卡已开 + OP + 后台 PDO 循环在跑。 - * 与 motor/transport-ecat.js 的 ensureOpen/ensureStarted 同一套 API, - * 只做布尔成败,不引入 transient/慢请求表(本页没有那套基础设施)。 + * 纯判据:给定 native/health 的回包,本页该不该动总线。单独导出是为了能被离线判死 + * (测试不必装 DOM)。判据本身仍只有 proveEcatLoop 一处实现。 + * @returns {"repair"|"idle-up"|"unproven"} */ -async function ensurePdoCycle() { - var st = null; - try { - st = await ecatPost('status', {}, 6000); - } catch (_) { - /* 后端未起:下面 open 会触发代理 _ecat_autostart */ - } - if (!st || st.ok !== true) { - var o = await ecatPost('open', { substr: adapterSubstr() }, 25000); - if (!o || o.ok !== true) return false; - } - /* 周期/自旋与电机页共用 localStorage,用户调过的档位不被本页覆盖。 */ - var body = {}; - try { - var v = parseFloat(localStorage.getItem('dcu_ecat_period_ms')); - if (v > 0) body.period_ms = v; - var sp = localStorage.getItem('dcu_ecat_spin'); - if (sp === '0' || sp === '1') body.spin = (sp === '1'); - } catch (_) {} - /* motor/start 内部未进 OP 时自己走 PREOP→SAFEOP→OP,最坏约 24s */ - var sr = await ecatPost('motor/start', body, 30000); - return !!(sr && sr.ok === true); +export function decidePdoCycle(h) { + /* treatOfflineAsDown:连不上/没开网卡时不存在"被我们停掉的健康循环",拉起是安全的 + * —— 这正是本页存在的意义(不必先开电机页也能刷写)。只有本页传这个旗标。 */ + var pr = proveEcatLoop(h, { treatOfflineAsDown: true }); + if (pr === "down") return "repair"; + if (pr === "up") return "idle-up"; + return "unproven"; } -/** 读数无效时尝试自动拉起。冷却 + 刷写避让。返回是否本次调用成功启动。 */ +/** + * 需要时才把总线拉起来:先证明循环停了,再走 网卡 open → motor/start。 + * 动作本身交给 ecat-bus.ensureEcatLoopRepair —— 它与电机页、Owner 共用同一份预算 + * (in-flight 锁 / 最小间隔 20s / 连续 3 次封顶),否则三个页面各自节流就等于三倍频率。 + * @returns {Promise} 'idle-up' | 'started' | 'unproven' | 'refused:' + */ +async function ensurePdoCycle() { + var h = null; + try { + h = await ecatPost('native/health', {}, 3000); + } catch (_) { + h = null; /* 代理 503 / native 未起:proveEcatLoop 会按 treatOfflineAsDown 归类 */ + } + if (decidePdoCycle(h) !== 'repair') { + if (proveEcatLoop(h) === 'up') noteEcatLoopUp(); + return decidePdoCycle(h); + } + var r = await ensureEcatLoopRepair(ecatPost, 'auto_loop_repair', { + treatOfflineAsDown: true, + /* 周期/自旋与电机页共用 localStorage,用户调过的档位不被本页覆盖。 */ + fromLocalStorage: true, + }); + return r.acted ? 'started' : 'refused:' + r.why; +} + +/** 读数无效时按需拉起。冷却 + 刷写避让。返回本次的判定(字符串协议见 ensurePdoCycle)。 */ async function tryAutoStart() { - if (busBusy()) return false; + if (busBusy()) return 'busy'; var now = Date.now(); - if (now - startAttemptAt < START_COOLDOWN_MS) return false; + if (now - startAttemptAt < START_COOLDOWN_MS) return 'cooldown'; startAttemptAt = now; try { - var ok = await ensurePdoCycle(); - if (ok) { - if (!startLogged) { - startLogged = true; - if (window.addLog) { - window.addLog('I', 'ECAT', - '已自动拉起 EtherCAT PDO 循环(GD32 负载将在首个 1s 窗口后显示)'); - } + var act = await ensurePdoCycle(); + if (act === 'started' && !startLogged) { + startLogged = true; + if (window.addLog) { + window.addLog('I', 'ECAT', + '已自动拉起 EtherCAT PDO 循环(GD32 负载将在首个 1s 窗口后显示)'); } - return true; } - } catch (_) { /* 代理/后端离线、open/start 失败:走退避 */ } - return false; + return act; + } catch (_) { + /* 代理/后端离线、open/start 失败:走退避 */ + return 'failed'; + } } async function tick() { @@ -174,12 +183,16 @@ async function tick() { if (ok) { failCount = 0; } else { - var started = false; - try { started = await tryAutoStart(); } catch (_) {} - if (started) { + var act = 'skipped'; + try { act = await tryAutoStart(); } catch (_) {} + if (act === 'started') { /* 刚 start 成功时 seq 仍可能是 0(首个 1s 窗口未滚完), * 温和计数:保持 1s 节拍等窗口,不要立刻 5s/30s 退避。 */ failCount = Math.min(failCount, 1); + } else if (act === 'idle-up') { + /* 循环确实在跑,只是读数不新鲜(固件 <1.5.61 段5 恒 0 / app 没跑 / 窗口没滚完)。 + * 这不是"该重启",继续敲门的唯一后果是白读几轮 —— 退到 30s 慢档。 */ + failCount = Math.max(failCount, 7); } else { failCount++; } diff --git a/scripts/check_fe_guard_mutation.py b/scripts/check_fe_guard_mutation.py new file mode 100644 index 0000000..cd0be94 --- /dev/null +++ b/scripts/check_fe_guard_mutation.py @@ -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()) diff --git a/scripts/test_ecat_autostart.mjs b/scripts/test_ecat_autostart.mjs index d3a9cb2..239d192 100644 --- a/scripts/test_ecat_autostart.mjs +++ b/scripts/test_ecat_autostart.mjs @@ -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] 打不开网卡时的错误语义"); {