From 99fb9bcb4aca1a390d154e669576f11898e6e6d1 Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Sat, 5 Sep 2026 19:58:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20app=20=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E7=A7=9F=E7=BA=A6,=20=E5=85=A8=E9=83=A8=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E8=87=AA=E5=8A=A8=E5=81=9C=20EtherCAT=20?= =?UTF-8?q?=E5=BE=AA=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDO 循环起来后 native 侧一直跑, 其 --parent-pid 自清理只覆盖"本服务拉起 的实例死掉"; 浏览器模式关窗后本服务还在、窗口模式复用现成 ecat_native (parent 是别人)这两种常见离开方式都够不着 —— 从站卡在 OP、自旋节拍白占 一个核。现由每个 app 页面与页面服务器的 WebSocket 租约兜底: - esp32_server.py: 新增 GET /ws/app-lease(手写 RFC6455 握手 + 帧泵)。 连接计数归零后隔 3s GRACE 调后端 /api/op/stop 停循环退回 INIT; 断开 感知靠 TCP FIN / Close 帧回显而非心跳(后台标签页节流不误停); 代理 透传时 _note_ecat_loop 记账"循环是否本服务跑起来的", 只停自己确认过 的; /api/shutdown 与 serve_forever 退出路径同步补停(定时器随进程死亡)。 - js/common/app-lease.js: 租约客户端, 3~30s 退避重连, file:// 静默跳过。 - 接入 tabs.html(外壳持有, 内部切标签不断连)与 pages/motor.html(单独 打开时页面自持)。 - 测试: scripts/test_app_lease.py(裸 TCP 全路径, 假 _ecat_post) + scripts/test_app_lease_e2e.mjs(真 Edge headless + 假后端 8091, 不碰 真总线)。README 补设计动机与要点。 --- README.md | 23 ++++ esp32_server.py | 209 +++++++++++++++++++++++++++++++++ js/common/app-lease.js | 43 +++++++ pages/motor.html | 2 + scripts/test_app_lease.py | 181 ++++++++++++++++++++++++++++ scripts/test_app_lease_e2e.mjs | 175 +++++++++++++++++++++++++++ tabs.html | 3 + 7 files changed, 636 insertions(+) create mode 100644 js/common/app-lease.js create mode 100644 scripts/test_app_lease.py create mode 100644 scripts/test_app_lease_e2e.mjs diff --git a/README.md b/README.md index 76e0421..8e00757 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,29 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口 最坏约 24s,所以前端超时给 30s、代理也给 30s)。 4. **循环没跑** → 同一个 `/api/motor/start` 起后台 PDO 循环。 断线后每 2s 自动重来一遍,中途拔网线再插会自己恢复。 +- **关掉 app 必停从站**(上面"自动接入"的反向操作,`esp32_server.py` 的 + `_app_lease_*` 一节):每个 app 页面与页面服务器保持一条 WebSocket + (`js/common/app-lease.js` → `GET /ws/app-lease`),**最后一条**租约连接断开 + (所有 app 窗口关闭)后,服务端调后端 `/api/op/stop` 停 PDO 循环并把从站退回 + INIT。为什么必须有它:循环在喂从站 1000ms 过程数据看门狗,没人停它就永远 + 卡在 OP、自旋节拍白占一个核;而 native 的 `--parent-pid` 看门狗只覆盖 + "本服务拉起的实例",浏览器模式关窗后服务还活着、复用现成实例时 parent + 又是别人,两种常见离开方式都够不着。要点: + - 断开感知靠 TCP FIN / Close 帧回显,**不是心跳计时器** —— 后台标签页的 + 定时器节流不会把"挂着后台的 app"误判成已关闭(服务端必须回 Close 回显, + 否则浏览器拆不掉 TCP,Edge 实测踩过); + - 断连后隔 3s GRACE 才停:F5 刷新只断几百毫秒,不该被当成关窗; + - 外壳内切标签不停(`tabs.html` 自己持有租约),与 `transport-ecat.teardown` + "切到 WiFi 看看不该打断 EtherCAT" 的既有设计一致; + - 只停"本服务经代理确认跑过"的循环(`g_ecat_motor_live` 代理记账), + 直接从 ecat 控制台(:8090)起的循环不动; + - 服务进程自己的退出路径(`/api/shutdown`、Ctrl+C)同步补停一次 —— + 租约定时器随进程死亡,等不到 GRACE 到点。 + 代价:每次重开 app 都要重走 PREOP→SAFEOP→OP,有概率撞 SSC 邮箱楔死 + (见下一段)—— 这是"关闭必须停"这个要求本身的代价,不是机制缺陷。 + 自测:`python scripts/test_app_lease.py`(服务端逻辑,裸 TCP 全路径)+ + `node scripts/test_app_lease_e2e.mjs`(真 Edge headless + 真服务器, + 假后端钉在 8091,绝不碰真总线)。 - 接入失败时**必须能看到原因**:横幅与 `#devStatus` 显示归类后的可执行提示 (`summarizeLinkError`),完整诊断放在两者的 `title` 里。 典型:SSC 邮箱楔死会把 64 条重复 `MailboxError` + SM 现场(2000+ 字符)压成 diff --git a/esp32_server.py b/esp32_server.py index 5c88b30..2027074 100644 --- a/esp32_server.py +++ b/esp32_server.py @@ -8,6 +8,8 @@ GET / → 302 到 /tabs.html (标签外壳, 工具主页) GET / → 本目录静态文件(tabs.html / pages/ / lab/ / css/ / js/) + GET /ws/app-lease → app 窗口租约 WebSocket: 所有 app 窗口断开后自动 + 停 EtherCAT PDO 循环并从站退回 INIT(见 _app_lease 一节) POST /api/shutdown → 结束本服务进程(窗口模式父进程用它通知子进程退出) POST /ecat/api/<路径> → 反向代理到 tools/ecat 的 ecat_native.exe 的 /api/<路径> @@ -32,8 +34,10 @@ pywebview 窗口没有浏览器刷新按钮/地址栏, 因此本服务器给 HTM _reload.html: 右下角 ⟳ 悬浮按钮 + F5/Ctrl+R 键盘拦截(WebView2 默认不响应)。 """ import argparse +import base64 import collections import ctypes +import hashlib import json import os import re @@ -347,6 +351,127 @@ def _ecat_proxy(path, body, timeout=6.0): return 503, body +# ── app 窗口租约: "关掉 app 就必须停掉从站" 的兜底 ──────────────────────── +# PDO 循环起来后是在 ecat_native 里一直跑的: 从站侧 1000ms 过程数据看门狗 +# 要求持续喂, 而 native 的自清理只覆盖"我拉起的进程死掉"这一种情况。用户 +# 最常遇到的离开方式恰恰不走那条路: +# - 浏览器模式(esp32_web.vbs / serve.bat): 关掉浏览器窗口后本服务还在, +# 循环就一直空转 —— 从站卡在 OP, 自旋节拍占满一个 P 核, 直到下次手工清场; +# - 窗口模式复用现成 native(如之前用 ecat_web.vbs 起过): 关窗只结束本服务的 +# 子进程, native 的 parent 是别人, 不会替它清场。 +# +# 解法: 每个 app 页面与本页服务器保持一条 WebSocket 连接 +# (js/common/app-lease.js → GET /ws/app-lease), 连接在 = "还有 app 窗口开着": +# - 不用心跳计时器: 断开靠 TCP FIN 感知, 浏览器后台标签页的定时器节流 +# 弄不死一条已建立的连接, 也就不会在后台开着 app 时误停从站; +# - 外壳内切标签不断连(外壳 tabs.html 自己也持有连接), +# 与 transport-ecat.teardown "切到 WiFi 看看不该打断 EtherCAT" 的既有设计一致; +# - 最后一条断开后**隔 GRACE 再停**: F5 刷新的断连→重连只有几百毫秒, +# 不留这个窗口会把"刷新页面"误判成"关闭 app"(每次误停都要重进 OP, +# 还平白多撞一次 SSC 邮箱楔死的风险); +# - 只停"我们确认跑过"的循环: g_ecat_motor_live 仅在本服务代理过成功的 +# /motor/start 时置位, 有人直接从 ecat 控制台(:8090)开的循环不受影响。 +# +# 停法用 /api/op/stop(内部先 loop_stop 再把从站写回 INIT): 确定性地"从站已停", +# 不留"等 1s 看门狗把自己踢出 OP"的中间态; 下次 motor/start 本来就要现读 +# AL 状态重走 PREOP→SAFEOP→OP, 提前回 INIT 不增加额外代价。 +APP_LEASE_PATH = "/ws/app-lease" +APP_LEASE_GRACE_S = 3.0 # 断连→重连的吸收窗口(页面刷新 / 新窗口接手) +_WS_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" # RFC 6455 握手固定常量 + +g_app_leases = 0 # 当前挂着的租约连接数 +g_app_lock = threading.Lock() +g_ecat_motor_live = False # 本服务确认过"PDO 循环在跑"(经代理观测) + + +def _ws_accept_key(key): + """Sec-WebSocket-Accept = base64(SHA1(key + magic)), RFC 6455。""" + return base64.b64encode( + hashlib.sha1((key + _WS_MAGIC).encode("ascii")).digest()).decode("ascii") + + +def _app_lease_release(): + global g_app_leases + with g_app_lock: + g_app_leases = max(0, g_app_leases - 1) + empty = g_app_leases == 0 + if empty: + _schedule_loop_release() + + +def _schedule_loop_release(attempt=0): + """GRACE 秒后若仍无一条租约连接且循环在跑, 停掉它。 + + 多个断连各自起一个定时器没关系 —— _check 里现读现判, 条件不满足就静默退出。 + """ + def _check(): + with g_app_lock: + if g_app_leases > 0: + return # 有窗口重连上了(F5/新窗口), 本次释放作废 + _ecat_stop_motor_loop(attempt) + t = threading.Timer(APP_LEASE_GRACE_S if attempt == 0 else 3.0, _check) + t.daemon = True + t.start() + + +def _ecat_stop_motor_loop(attempt, why="app 全部窗口已关闭"): + """经代理让后端停循环 + 从站退回 INIT。幂等, 失败最多再试 2 轮。""" + global g_ecat_motor_live + with g_app_lock: + if not g_ecat_motor_live: + return + port = _ecat_find_port() + if port is None: + with g_app_lock: + g_ecat_motor_live = False # 后端本来就不在了, 没东西可停 + return + ok = False + try: + status, data = _ecat_post(port, "/api/op/stop", b"{}", 5.0) + ok = status == 200 and _json_ok(data) + except Exception: + pass + if ok: + with g_app_lock: + g_ecat_motor_live = False + print(f"[lease] {why} → EtherCAT PDO 循环已停, 从站退回 INIT", flush=True) + return + if attempt < 2: + _schedule_loop_release(attempt + 1) # 后端可能正忙(持有 bus 锁), 稍后再试 + else: + with g_app_lock: + g_ecat_motor_live = False + print("[lease] 警告: app 已关闭但 EtherCAT 循环停止失败" + "(服务无响应?), 请手工关闭 EtherCAT 服务", flush=True) + + +def _json_ok(data): + """后端信封 ok:true 的判定。不能用子串匹配: json.dumps 会写成 "ok": true + (带空格), 真 native 是 C 打印的紧凑格式 —— 两种都得认, 低频路由正经解析一次。""" + try: + return json.loads(data.decode("utf-8", "replace")).get("ok") is True + except Exception: + return False + + +def _note_ecat_loop(path, status, body): + """代理透传时顺手记下"循环跑没跑", 免得租约空了去停别人的或白停一次。 + + 判定只针对低频路由(motor/state 那种高频轮询不在表内, 不进这里分支)。 + """ + global g_ecat_motor_live + tail = path[len(ECAT_PROXY_PREFIX):] + if tail == "motor/start": + target, flag = True, True + elif tail in ("motor/stop", "op/stop", "op/start", "open", "close", "shutdown"): + target, flag = True, False + else: + return + if status == 200 and _json_ok(body): # 后端 200 + ok:true 才算数 + with g_app_lock: + g_ecat_motor_live = flag + + class Handler(SimpleHTTPRequestHandler): server_version = "Esp32Tools/1.0" @@ -363,6 +488,9 @@ class Handler(SimpleHTTPRequestHandler): def do_GET(self): path = self.path.split("?")[0].lower() + if path == APP_LEASE_PATH: + self._handle_ws_app_lease() + return # 根路径不再有 index.html(已移入 pages/), 直接送去标签外壳 if path in ("/", "/index.html"): self.send_response(302) @@ -418,10 +546,78 @@ class Handler(SimpleHTTPRequestHandler): "/flash", "/eeprom/write", "/eeprom/read", "/eeprom/verify")) else 6.0 status, body = _ecat_proxy(path, raw, timeout) + _note_ecat_loop(path, status, body) self._post_json(body, status) return self.send_error(404) + def _handle_ws_app_lease(self): + """app 窗口租约 WebSocket(契约见 APP_LEASE_PATH 一节)。 + + 只做握手然后盯断开: 浏览器发来什么都不需要回应(连 WS 帧都不用解析, + 读到的字节直接丢弃), 本线程唯一的产出是"断开时调一次 release"。 + ThreadingHTTPServer 每连接一线程, 这里每个 app 窗口占一条阻塞在 + recv 上的线程, 数量就是窗口数, 可以忽略。 + """ + key = self.headers.get("Sec-WebSocket-Key") or "" + if "websocket" not in (self.headers.get("Upgrade") or "").lower() or not key: + self.send_error(400, "此端点仅支持 WebSocket 握手") + return + self.wfile.write( + ("HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Accept: {_ws_accept_key(key)}\r\n" + "\r\n").encode("ascii")) + self.wfile.flush() + global g_app_leases + with g_app_lock: + g_app_leases += 1 + try: + self._ws_pump(self.connection) + except OSError: + pass # 强杀/复位: 连接报错同样算断开 + finally: + _app_lease_release() + + @staticmethod + def _ws_pump(conn): + """读掉对端的 WS 帧直到连接结束, 唯一的产出是"断开时返回"。 + + 不能只 recv 丢弃: 浏览器/客户端关连接前会先发 Close 控制帧并**等回显** + 才拆 TCP(Edge 实测: 不回则导航走后半条连接挂着, FIN 永远等不到, + 租约不释放)。所以 Close(0x8) 必须回一个空 Close, Ping(0x9) 顺手回 + Pong(0xA); 文本/二进制帧没有语义, 读掉即可。 + """ + rf = conn.makefile("rb") + while True: + hdr = rf.read(2) + if len(hdr) < 2: + return # FIN: 对端已关闭 + op = hdr[0] & 0x0F + ln = hdr[1] & 0x7F + if ln == 126: + ln = int.from_bytes(rf.read(2), "big") + elif ln == 127: + ln = int.from_bytes(rf.read(8), "big") + if ln > 65536: + return # 租约通道不该有大帧, 当协议错误断开 + mask = rf.read(4) if hdr[1] & 0x80 else b"" + payload = rf.read(ln) if ln else b"" + if mask and payload: + payload = bytes(c ^ mask[i & 3] for i, c in enumerate(payload)) + if op == 0x8: # Close → 回显空 Close, 租约到此为止 + try: + conn.sendall(b"\x88\x00") + except OSError: + pass + return + if op == 0x9: # Ping → Pong(原样带回 payload) + try: + conn.sendall(bytes([0x8A, len(payload)]) + payload) + except OSError: + pass + def _post_json(self, body, status=200): self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") @@ -431,6 +627,14 @@ class Handler(SimpleHTTPRequestHandler): def _shutdown_self(): + # 退出前把"我们启动过的"EtherCAT 循环先停掉。租约断开后的停循环靠 3s 延迟 + # 定时器, 而定时器随本进程死亡一起消失; 窗口模式(_stop_child → 本路由)是 + # 正常关窗的主路径, 复用的 ecat_native(parent 是别的进程)不会替我们收尾。 + # attempt=2 表示尽力一次即止, 不在退出路径上重试磨蹭。 + try: + _ecat_stop_motor_loop(2, why="本服务进程退出") + except Exception: + pass if g_httpd is not None: try: g_httpd.shutdown() @@ -520,6 +724,11 @@ def _run_browser(args): httpd.serve_forever() except KeyboardInterrupt: pass + # 同 _shutdown_self: 进程要没了, 别让租约定时器带着"停循环"的责任一起死 + try: + _ecat_stop_motor_loop(2, why="本服务进程退出") + except Exception: + pass httpd.server_close() print("\n已退出") diff --git a/js/common/app-lease.js b/js/common/app-lease.js new file mode 100644 index 0000000..11fe42c --- /dev/null +++ b/js/common/app-lease.js @@ -0,0 +1,43 @@ +/** + * app-lease.js — "app 窗口还开着"的凭证。 + * + * 与 esp32_server.py 的 /ws/app-lease 保持一条 WebSocket。服务端在 + * **所有** 租约连接断开后的宽限期结束时, 自动把 EtherCAT PDO 循环停掉、 + * 从站退回 INIT —— 这是"关掉 app 从站必须停"的兜底, 无论 ecat_native + * 是谁拉起的(本服务复用现成实例时, 它的 --parent-pid 看门狗够不着)。 + * + * 为什么用 WebSocket 而不是心跳轮询: 已建立的连接不受浏览器后台标签页 + * 定时器节流影响(那会把"挂在后台的 app"误判成已关闭), 断开靠 TCP FIN + * 即时感知, 不依赖任何计时器。服务端另有 3s 断连吸收窗口, 页面刷新不会误停。 + * + * 接入位置: tabs.html(外壳, 覆盖整个 app 窗口生命周期) 和 pages/motor.html + * (单独打开电机页时它自己就是窗口)。同一窗口里两条连接并存没问题, + * 服务端判据是"连接数归零"。断线自动重连; file:// 直开页面时静默跳过。 + */ +(function () { + "use strict"; + if (location.protocol !== "http:" && location.protocol !== "https:") return; + var url = (location.protocol === "https:" ? "wss://" : "ws://") + + location.host + "/ws/app-lease"; + var retryTimer = null; + var delay = 3000; + + function scheduleReconnect() { + if (retryTimer) return; + // 3~30s 递增退避: 服务没起/刚重启时不去刷屏, 起来后最多等半分钟接上。 + retryTimer = setTimeout(function () { + retryTimer = null; + delay = Math.min(delay * 2, 30000); + connect(); + }, delay); + } + + function connect() { + var ws; + try { ws = new WebSocket(url); } catch (e) { scheduleReconnect(); return; } + ws.onopen = function () { delay = 3000; }; + ws.onclose = function () { scheduleReconnect(); }; + ws.onerror = function () { try { ws.close(); } catch (e) {} }; + } + connect(); +})(); diff --git a/pages/motor.html b/pages/motor.html index 16038ad..9398b76 100644 --- a/pages/motor.html +++ b/pages/motor.html @@ -400,5 +400,7 @@ + + \ No newline at end of file diff --git a/scripts/test_app_lease.py b/scripts/test_app_lease.py new file mode 100644 index 0000000..d8358aa --- /dev/null +++ b/scripts/test_app_lease.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""test_app_lease.py — 验证 "app 全部窗口关闭 → 自动停 EtherCAT 循环" 链路。 + +不打真实 EtherCAT 硬件: 导入 esp32_server 后把 _ecat_post/_ecat_find_port +换成假实现, 其余(WS 握手、租约计数、GRACE 吸收、代理记账)全部走真实代码。 + +用法: python scripts/test_app_lease.py +""" +import base64 +import hashlib +import os +import socket +import sys +import threading +import time +import urllib.request + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import esp32_server as srv # noqa: E402 + +WS_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" +calls = [] # 假 _ecat_post 记录到的请求路径 +calls_lock = threading.Lock() + + +def fake_post(port, path, body, timeout): + with calls_lock: + calls.append(path) + # 带空格的 "ok": true —— 故意与真 native 的紧凑格式不同, + # 锁死"代理记账不能靠子串匹配"这条回归(e2e 里就是这么炸过一次)。 + return 200, b'{"ok": true, "log": ["fake"]}' + + +def ws_connect(port): + """打开一条真实 TCP 上的 WebSocket, 校验 101 握手, 返回 socket。""" + s = socket.create_connection(("127.0.0.1", port)) + key = base64.b64encode(os.urandom(16)).decode() + s.sendall(("GET /ws/app-lease HTTP/1.1\r\n" + f"Host: 127.0.0.1:{port}\r\n" + "Upgrade: websocket\r\nConnection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n\r\n").encode("ascii")) + head = b"" + while b"\r\n\r\n" not in head: + chunk = s.recv(4096) + if not chunk: + raise AssertionError("握手被直接关闭") + head += chunk + text = head.decode("ascii", "replace") + assert "101" in text.splitlines()[0], f"握手没回 101: {text[:120]}" + expect = base64.b64encode( + hashlib.sha1((key + WS_MAGIC).encode()).digest()).decode() + assert expect in text, "Sec-WebSocket-Accept 不对" + return s + + +def ws_graceful_close(s): + """浏览器关 WS 的真实行为: 先发掩码 Close 帧, 等服务端回显后才拆 TCP。 + 服务端若不回 Close(RFC 6455 要求), 这条链路的 FIN 永远到不了 → 租约不释放。""" + s.sendall(b"\x88\x80" + b"\x00\x00\x00\x00") # FIN+Close, masked, len 0 + s.settimeout(3.0) + echo = s.recv(2) + assert echo == b"\x88\x00", f"Close 回显不对: {echo!r}" + s.close() + + +def post_json(port, path): + req = urllib.request.Request( + f"http://127.0.0.1:{port}{path}", data=b"{}", + headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=10) as r: + return r.status, r.read() + + +def flag(): + with srv.g_app_lock: + return srv.g_ecat_motor_live + + +def wait_flag(want, timeout=8.0): + deadline = time.time() + timeout + while time.time() < deadline: + if flag() == want: + return True + time.sleep(0.05) + return False + + +def main(): + # 加速测试: GRACE 从 3s 缩到 0.3s(重试轮次间隔同用该值, 一并缩短无妨) + srv.APP_LEASE_GRACE_S = 0.3 + srv._ecat_post = fake_post + srv._ecat_find_port = lambda: 9999 + + # 找空闲端口起真服务 + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + httpd, port = srv._bind_server(port) + assert httpd is not None, "起不了测试服务" + threading.Thread(target=httpd.serve_forever, daemon=True).start() + + # 1) 非 WS 的普通 GET 不受影响 + with urllib.request.urlopen(f"http://127.0.0.1:{port}/tabs.html", timeout=5) as r: + assert r.status == 200 and b"tabbar" in r.read(), "普通静态页被 WS 路由挡住了" + print("PASS 1: 静态页照常服务") + + # 2) 经代理的 motor/start 置位循环标志 + post_json(port, "/ecat/api/motor/start") + assert flag() is True, "motor/start 经代理后没把 g_ecat_motor_live 置位" + print("PASS 2: 代理记账 motor/start → live=True") + + # 3) 两条租约, 关一条 → 不该停 + a = ws_connect(port) + b = ws_connect(port) + time.sleep(0.1) + a.close() + time.sleep(1.0) # > GRACE + assert "/api/op/stop" not in calls, "还剩一个窗口就停了循环!" + print("PASS 3: 仍有窗口在线时不停") + + # 4) 全关 → GRACE 后自动 op/stop (走浏览器式 Close 帧回显路径, + # 与场景 3 的裸 FIN 路径各覆盖一种断开方式) + ws_graceful_close(b) + deadline = time.time() + 5 + while time.time() < deadline and "/api/op/stop" not in calls: + time.sleep(0.05) + assert "/api/op/stop" in calls, "全部断开后没停循环" + assert wait_flag(False), "停完没清 live 标志" + print("PASS 4: 最后一条租约断开 → /api/op/stop") + + # 5) 刷新吸收: 断开后在 GRACE 内重连 → 不该停 + with calls_lock: + calls.clear() + post_json(port, "/ecat/api/motor/start") + c = ws_connect(port) + c.close() + d = ws_connect(port) # 模拟刷新重连(远快于 GRACE) + time.sleep(1.0) + assert "/api/op/stop" not in calls, "页面刷新被误判成关闭 app!" + print("PASS 5: 刷新重连被 GRACE 吸收") + + # 6) 用户显式 motor/stop 后, 关窗口不该再打 op/stop + d.close() + time.sleep(1.0) + with calls_lock: + calls.clear() + post_json(port, "/ecat/api/motor/start") + post_json(port, "/ecat/api/motor/stop") + assert flag() is False, "motor/stop 没清 live 标志" + e = ws_connect(port) + e.close() + time.sleep(1.0) + assert "/api/op/stop" not in calls, "循环本来就没跑还去停(多此一举)" + print("PASS 6: 循环已停时不再重复停") + + # 7) 窗口模式竞态: 服务进程在 GRACE 定时器触发前就退出(/api/shutdown), + # 且租约连接还挂着 —— 退出路径必须同步把循环停掉, 不能指望定时器 + # (复用的 ecat_native 的 parent 不是本服务, 没人替它清场)。 + srv.APP_LEASE_GRACE_S = 30.0 # 定时器"永不"到点, 证明停的是退出路径 + srv.g_httpd = httpd + post_json(port, "/ecat/api/motor/start") + assert flag() is True + with calls_lock: + calls.clear() + f = ws_connect(port) # 租约仍在(未断开) + post_json(port, "/api/shutdown") + deadline = time.time() + 5 + while time.time() < deadline and "/api/op/stop" not in calls: + time.sleep(0.05) + assert "/api/op/stop" in calls, "服务进程退出前没停循环(窗口模式竞态)" + assert wait_flag(False, timeout=2.0) + f.close() + print("PASS 7: 服务进程退出时同步停循环") + + httpd.shutdown() + print("ALL PASS") + + +if __name__ == "__main__": + main() diff --git a/scripts/test_app_lease_e2e.mjs b/scripts/test_app_lease_e2e.mjs new file mode 100644 index 0000000..3e5c1f9 --- /dev/null +++ b/scripts/test_app_lease_e2e.mjs @@ -0,0 +1,175 @@ +/** + * test_app_lease_e2e.mjs — app 租约的真浏览器端到端验证。 + * + * 与 test_app_lease.py 的分工: 那边用裸 TCP 验服务端逻辑; 这边用真 Edge + * (headless) 验 app-lease.js 在浏览器引擎里真的能握手/断连, 以及 + * "多窗口只关一个不停、全关才停" 的真实时序。 + * + * 不碰真硬件: 真 ecat_native 可能正占着 8090(它一收 motor/start 就会去动 + * 真总线)。所以假后端放 8091, esp32_server 以"仅改 ECAT_PORTS/autostart + * 两个常量"的方式跑起来 —— Handler、代理、租约、GRACE 全是真实代码。 + * + * 用法: node scripts/test_app_lease_e2e.mjs (退出码 0 = 通过) + */ +import { spawn, execSync } from "node:child_process"; +import net from "node:net"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const ROOT = path.resolve(import.meta.dirname, ".."); +const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe"; +const FAKE_PORT = 8091; +const DBG_PORT = 9334; +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dcu-lease-")); +const FAKE_LOG = path.join(tmp, "fake.log"); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const procs = []; +let failed = false; + +function check(name, cond, detail) { + if (cond) console.log(" ✓ " + name); + else { failed = true; console.log(" ✗ " + name + (detail ? " — " + detail : "")); } +} +function fakeLog() { + try { return fs.readFileSync(FAKE_LOG, "utf8"); } catch (_) { return ""; } +} +function waitPort(port, timeoutMs = 15000) { + return new Promise((res, rej) => { + const t0 = Date.now(); + (function poll() { + const s = net.connect(port, "127.0.0.1"); + s.on("connect", () => { s.destroy(); res(); }); + s.on("error", () => { + if (Date.now() - t0 > timeoutMs) rej(new Error("port " + port + " never opened")); + else setTimeout(poll, 200); + }); + })(); + }); +} +function freePort() { + return new Promise((res) => { + const s = net.createServer(); + s.listen(0, "127.0.0.1", () => { const p = s.address().port; s.close(() => res(p)); }); + }); +} +function killTree(pid) { + try { execSync(`taskkill /PID ${pid} /T /F`, { stdio: "ignore" }); } catch (_) {} +} + +/* ── 1. 假 EtherCAT 后端(8091): 记录请求路径, 一律回 ok:true ── */ +fs.writeFileSync(path.join(tmp, "fake_ecat.py"), ` +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +log = sys.argv[2] +class H(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.0" + def do_POST(self): + n = int(self.headers.get("Content-Length") or 0) + self.rfile.read(n) + with open(log, "a") as f: + f.write(self.path + "\\n") + # 紧凑格式, 与真 native(C snprintf 打印)逐字节同构 + body = b'{"ok":true,"log":["fake"]}' + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def log_message(self, *a): + pass +ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), H).serve_forever() +`); +procs.push(spawn("python", [path.join(tmp, "fake_ecat.py"), String(FAKE_PORT), FAKE_LOG], { stdio: "ignore" })); +await waitPort(FAKE_PORT); + +/* ── 2. 真 esp32_server, 仅把代理端口表钉到 8091、禁掉 autostart ── */ +const srvPort = await freePort(); +fs.writeFileSync(path.join(tmp, "srv.py"), ` +import sys +sys.path.insert(0, ${JSON.stringify(ROOT)}) +import esp32_server as srv +srv.ECAT_PORTS = (${FAKE_PORT},) +srv._ecat_autostart = lambda: (None, []) +httpd, p = srv._bind_server(${srvPort}) +print("PORT=%d" % p, flush=True) +httpd.serve_forever() +`); +const server = spawn("python", [path.join(tmp, "srv.py")], { cwd: ROOT }); +procs.push(server); +let serverPort = 0; +await new Promise((res, rej) => { + server.stdout.on("data", (d) => { + const m = /PORT=(\d+)/.exec(String(d)); + if (m) { serverPort = Number(m[1]); res(); } + }); + server.stderr.on("data", (d) => console.log("[srv] " + String(d).trim())); + setTimeout(() => rej(new Error("server 未报端口")), 15000); +}); +const BASE = `http://127.0.0.1:${serverPort}`; + +/* ── 3. 真 Edge headless + CDP ── */ +const edge = spawn(EDGE, [ + "--headless=new", `--remote-debugging-port=${DBG_PORT}`, + `--user-data-dir=${path.join(tmp, "profile")}`, + "--no-first-run", "--disable-gpu", "about:blank", +], { stdio: "ignore" }); +procs.push(edge); +await waitPort(DBG_PORT); + +let page; +for (let i = 0; i < 40 && !page; i++) { + await sleep(250); + try { + const list = await (await fetch(`http://127.0.0.1:${DBG_PORT}/json/list`)).json(); + page = list.find((t) => t.type === "page"); + } catch (_) {} +} +if (!page) throw new Error("拿不到 Edge 调试页面目标"); +const cdp = new WebSocket(page.webSocketDebuggerUrl); +await new Promise((r, j) => { cdp.onopen = r; cdp.onerror = j; }); +let mid = 0; +const pend = new Map(); +cdp.onmessage = (e) => { + const m = JSON.parse(e.data); + if (m.id && pend.has(m.id)) { pend.get(m.id)(m); pend.delete(m.id); } +}; +const send = (method, params = {}) => new Promise((res) => { + const id = ++mid; pend.set(id, res); cdp.send(JSON.stringify({ id, method, params })); +}); + +/* ── 4. 场景一: 单窗口, 关窗 → 自动停 ── */ +await send("Page.enable"); +await send("Page.navigate", { url: BASE + "/tabs.html" }); +await sleep(2500); +const r1 = await fetch(BASE + "/ecat/api/motor/start", { + method: "POST", headers: { "content-type": "application/json" }, body: "{}" }); +check("motor/start 经代理成功", r1.ok && (await r1.json()).ok === true); +check("假后端收到 /api/motor/start", fakeLog().includes("/api/motor/start")); +await sleep(4500); // > GRACE(3s): 窗口还开着, 绝不能停 +check("窗口在线期间没停循环", !fakeLog().includes("/api/op/stop")); +await send("Page.navigate", { url: "about:blank" }); // 离开页面 → 租约断开 +await sleep(5000); +check("关窗(GRACE 后)自动 /api/op/stop", fakeLog().includes("/api/op/stop")); + +/* ── 5. 场景二: 双窗口, 关一个不停, 全关才停 ── */ +fs.writeFileSync(FAKE_LOG, ""); +const t2 = await send("Target.createTarget", { url: BASE + "/tabs.html" }); +await sleep(2500); +await fetch(BASE + "/ecat/api/motor/start", { + method: "POST", headers: { "content-type": "application/json" }, body: "{}" }); +await send("Page.navigate", { url: BASE + "/tabs.html" }); // 窗口1 回来续租 +await sleep(1500); +await send("Target.closeTarget", { targetId: t2.result.targetId }); +await sleep(4500); +check("还剩一个窗口时不停", !fakeLog().includes("/api/op/stop")); +await send("Page.navigate", { url: "about:blank" }); // 最后一个也关掉 +await sleep(5000); +check("全部窗口关闭后自动停", fakeLog().includes("/api/op/stop")); + +/* ── 6. 收尾 ── */ +try { cdp.close(); } catch (_) {} +for (const p of procs) killTree(p.pid); +try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} +console.log(failed ? "E2E FAIL" : "E2E ALL PASS"); +process.exit(failed ? 1 : 0); diff --git a/tabs.html b/tabs.html index d8ae09e..f2bfb3f 100644 --- a/tabs.html +++ b/tabs.html @@ -127,5 +127,8 @@ go(init, false); })(); + +