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
+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()