feat(server): app 窗口租约, 全部窗口关闭自动停 EtherCAT 循环

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 补设计动机与要点。
This commit is contained in:
cbb
2026-09-05 19:58:25 +08:00
parent c189a8563e
commit 99fb9bcb4a
7 changed files with 636 additions and 0 deletions
+23
View File
@@ -302,6 +302,29 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
最坏约 24s,所以前端超时给 30s、代理也给 30s)。 最坏约 24s,所以前端超时给 30s、代理也给 30s)。
4. **循环没跑** → 同一个 `/api/motor/start` 起后台 PDO 循环。 4. **循环没跑** → 同一个 `/api/motor/start` 起后台 PDO 循环。
断线后每 2s 自动重来一遍,中途拔网线再插会自己恢复。 断线后每 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` 显示归类后的可执行提示 - 接入失败时**必须能看到原因**:横幅与 `#devStatus` 显示归类后的可执行提示
(`summarizeLinkError`),完整诊断放在两者的 `title` 里。 (`summarizeLinkError`),完整诊断放在两者的 `title` 里。
典型:SSC 邮箱楔死会把 64 条重复 `MailboxError` + SM 现场(2000+ 字符)压成 典型:SSC 邮箱楔死会把 64 条重复 `MailboxError` + SM 现场(2000+ 字符)压成
+209
View File
@@ -8,6 +8,8 @@
GET / → 302 到 /tabs.html (标签外壳, 工具主页) GET / → 302 到 /tabs.html (标签外壳, 工具主页)
GET /<file> → 本目录静态文件(tabs.html / pages/ / lab/ / css/ / js/) GET /<file> → 本目录静态文件(tabs.html / pages/ / lab/ / css/ / js/)
GET /ws/app-lease → app 窗口租约 WebSocket: 所有 app 窗口断开后自动
停 EtherCAT PDO 循环并从站退回 INIT(见 _app_lease 一节)
POST /api/shutdown → 结束本服务进程(窗口模式父进程用它通知子进程退出) POST /api/shutdown → 结束本服务进程(窗口模式父进程用它通知子进程退出)
POST /ecat/api/<路径> → 反向代理到 tools/ecat 的 ecat_native.exe 的 /api/<路径> POST /ecat/api/<路径> → 反向代理到 tools/ecat 的 ecat_native.exe 的 /api/<路径>
@@ -32,8 +34,10 @@ pywebview 窗口没有浏览器刷新按钮/地址栏, 因此本服务器给 HTM
_reload.html: 右下角 ⟳ 悬浮按钮 + F5/Ctrl+R 键盘拦截(WebView2 默认不响应)。 _reload.html: 右下角 ⟳ 悬浮按钮 + F5/Ctrl+R 键盘拦截(WebView2 默认不响应)。
""" """
import argparse import argparse
import base64
import collections import collections
import ctypes import ctypes
import hashlib
import json import json
import os import os
import re import re
@@ -347,6 +351,127 @@ def _ecat_proxy(path, body, timeout=6.0):
return 503, body 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): class Handler(SimpleHTTPRequestHandler):
server_version = "Esp32Tools/1.0" server_version = "Esp32Tools/1.0"
@@ -363,6 +488,9 @@ class Handler(SimpleHTTPRequestHandler):
def do_GET(self): def do_GET(self):
path = self.path.split("?")[0].lower() path = self.path.split("?")[0].lower()
if path == APP_LEASE_PATH:
self._handle_ws_app_lease()
return
# 根路径不再有 index.html(已移入 pages/), 直接送去标签外壳 # 根路径不再有 index.html(已移入 pages/), 直接送去标签外壳
if path in ("/", "/index.html"): if path in ("/", "/index.html"):
self.send_response(302) self.send_response(302)
@@ -418,10 +546,78 @@ class Handler(SimpleHTTPRequestHandler):
"/flash", "/eeprom/write", "/flash", "/eeprom/write",
"/eeprom/read", "/eeprom/verify")) else 6.0 "/eeprom/read", "/eeprom/verify")) else 6.0
status, body = _ecat_proxy(path, raw, timeout) status, body = _ecat_proxy(path, raw, timeout)
_note_ecat_loop(path, status, body)
self._post_json(body, status) self._post_json(body, status)
return return
self.send_error(404) 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): def _post_json(self, body, status=200):
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Type", "application/json; charset=utf-8")
@@ -431,6 +627,14 @@ class Handler(SimpleHTTPRequestHandler):
def _shutdown_self(): 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: if g_httpd is not None:
try: try:
g_httpd.shutdown() g_httpd.shutdown()
@@ -520,6 +724,11 @@ def _run_browser(args):
httpd.serve_forever() httpd.serve_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
# 同 _shutdown_self: 进程要没了, 别让租约定时器带着"停循环"的责任一起死
try:
_ecat_stop_motor_loop(2, why="本服务进程退出")
except Exception:
pass
httpd.server_close() httpd.server_close()
print("\n已退出") print("\n已退出")
+43
View File
@@ -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();
})();
+2
View File
@@ -400,5 +400,7 @@
</div> </div>
<script type="module" src="../js/motor/main.js?v=20260905a"></script> <script type="module" src="../js/motor/main.js?v=20260905a"></script>
<!-- 单独打开本页(不在标签外壳里)时, 由本页自己持有 app 租约 -->
<script src="../js/common/app-lease.js"></script>
</body> </body>
</html> </html>
+181
View File
@@ -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()
+175
View File
@@ -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);
+3
View File
@@ -127,5 +127,8 @@
go(init, false); go(init, false);
})(); })();
</script> </script>
<!-- app 窗口租约: 本外壳窗口活着期间保持与本地服务的连接,
全部窗口关闭后服务端自动停 EtherCAT PDO 循环(见 js/common/app-lease.js) -->
<script src="js/common/app-lease.js"></script>
</body> </body>
</html> </html>