fix(server): P0 代理非幂等不重发 + 跨进程 native 互斥复用 + 默认网卡 I210

R1: autostart 后禁止自动重发 flash/motor/send 等副作用路径;
    超时类失败也不再串打其它端口。
R2: README 默认网卡改为与代码一致的 I210。
R3: 命名互斥锁 + 先复用健康监听实例,避免 GUI/服务子进程互相 kill。
This commit is contained in:
cbb
2026-09-21 02:14:16 +08:00
parent 27a8fc1797
commit 67dd1702f0
2 changed files with 190 additions and 72 deletions
+3 -2
View File
@@ -320,8 +320,9 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
开着的实例关掉、从站带回 INIT);拉起时**必须带 `--parent-pid`**,既跳过
那段清场,也让它随本服务退出而自我了断,不留孤儿占着 Npcap 适配器和端口。
2. **网卡没开** → `ensureOpen()` 调 `/api/open`,代替用户去 `ecat.html` 点"连接"。
网卡匹配子串在工具栏「链路」右边的输入框里改(默认 `I226`,
存 `localStorage` 的 `dcu_ecat_adapter`)。
网卡匹配子串在工具栏「链路」右边的输入框里改(默认 `I210`,
存 `localStorage` 的 `dcu_ecat_adapter`;与 `device-link.js` /
`esp32_server.py` autostart / 页面输入框一致)。
3. **没进 OP** → `/api/motor/start` 内部自己走 PREOP→SAFEOP→OP(会自动重试一次,
最坏约 24s,所以前端超时给 30s、代理也给 30s)。
4. **循环没跑** → 同一个 `/api/motor/start` 起后台 PDO 循环。
+187 -70
View File
@@ -131,7 +131,7 @@ user-select:none;opacity:.55;transition:opacity .15s}
_RELOAD_SNIPPET = _RELOAD_SNIPPET.replace("__HOME__", "/tabs.html")
_RELOAD_MARK = "</body>"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# BASE_DIR 在文件头已定义(L56),此处不得重复绑定
g_httpd = None
g_child = None
@@ -186,9 +186,63 @@ def _resolve_ecat_dir(base_dir):
ECAT_DIR = _resolve_ecat_dir(BASE_DIR)
ECAT_NATIVE_EXE = os.path.join(ECAT_DIR, "build_native", "ecat_native.exe")
# 网卡默认子串:与 js/common/device-link.js / pages/motor.html 保持一致(R2)
ECAT_DEFAULT_ADAPTER = "I210"
g_ecat_child = None # 由本服务拉起的 ecat 服务子进程(复用已有实例时为 None)
g_ecat_log_fp = None # native stdout 落盘句柄(见 _ecat_autostart 内注释)
g_ecat_lock = threading.Lock() # 串行化自动启动, 避免并发请求拉起多个进程
# R3: 跨进程互斥(GUI 进程 + 服务子进程都可能 autostart/kill native)
_ECAT_XPROC_MUTEX_NAME = "Local\\DCU_Esp32Tools_EcatNativeProc"
class _EcatProcXLock:
"""Windows 命名互斥锁:串行化所有会 kill/拉起 ecat_native 的路径。"""
def __init__(self, timeout_ms=60000):
self.timeout_ms = timeout_ms
self._handle = None
self._owned = False
def __enter__(self):
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
self._handle = k32.CreateMutexW(None, False, _ECAT_XPROC_MUTEX_NAME)
if not self._handle:
return self
r = k32.WaitForSingleObject(self._handle, self.timeout_ms)
# WAIT_OBJECT_0=0, WAIT_ABANDONED=0x80
self._owned = r in (0, 0x80)
return self
def __exit__(self, *exc):
if not self._handle:
return False
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
try:
if self._owned:
k32.ReleaseMutex(self._handle)
finally:
k32.CloseHandle(self._handle)
self._handle = None
return False
def _ecat_reuse_listening_native():
"""端口上已有健康 native → 直接复用(可能是另一进程刚拉起的),不 kill。"""
ports = ([g_ecat_port] if g_ecat_port else []) + \
[p for p in ECAT_PORTS if p != g_ecat_port]
for p in ports:
if not _port_taken(p):
continue
try:
status, data = _ecat_post(
p, "/api/native/health", b"{}", 1.0,
connect_timeout=0.4)
if status == 200:
return p
except Exception:
continue
return None
# ── PDO 快发(ESF1)── tools/ecat ecat_native 的二进制直通 ──────────
# 布局与 docs/compose/spec/pdo-fast-send.md 一致。**仅** native 专用 TCP 口
@@ -582,88 +636,133 @@ def _kill_all_ecat_native():
def _ecat_autostart():
"""按需拉起 tools/ecat 的服务进程。返回 (port, log_lines)。
单实例策略(2026-09-12):
单实例策略(2026-09-12,R3 加固 2026-09-23):
- 跨进程命名互斥锁串行化 kill/start(GUI 进程与服务子进程共用)。
- **先探测健康监听实例并复用**,绝不 kill 对方刚拉起的 native。
- 本进程子进程仍存活 → 只等它听口,**绝不 kill_all**(否则启动中的实例
会被自己下一次请求杀掉,形成 8s kill_stale 死循环)。
- 子进程已死 / 从未拉起 → 先 taskkill 全部残留 ecat_native,再起唯一实例。
- 子进程已死 / 从未拉起且无健康监听 → 先 taskkill 全部残留 ecat_native,再起唯一实例。
port 为 None 表示拉不起来。
"""
global g_ecat_child, g_ecat_port, g_ecat_log_fp
with g_ecat_lock:
logs = []
with _EcatProcXLock():
with g_ecat_lock:
logs = []
# ── 我们自己拉起的还活着:只复用/等它,不杀 ──
if g_ecat_child is not None and g_ecat_child.poll() is None:
port = _ecat_find_port()
if port:
g_ecat_port = port
return port, []
# 仍在启动(kill_stale/绑口最多约 10s):等完再判
deadline = time.time() + 10
while time.time() < deadline:
if g_ecat_child.poll() is not None:
break
# ── R3: 另一进程(GUI/服务)已在跑的健康实例 → 复用 ──
reuse = _ecat_reuse_listening_native()
if reuse:
g_ecat_port = reuse
return reuse, [f"复用已在监听的 EtherCAT native (端口 {reuse})"]
# ── 我们自己拉起的还活着:只复用/等它,不杀 ──
if g_ecat_child is not None and g_ecat_child.poll() is None:
port = _ecat_find_port()
if port:
g_ecat_port = port
return port, []
# 仍在启动(kill_stale/绑口最多约 10s):等完再判
deadline = time.time() + 10
while time.time() < deadline:
if g_ecat_child.poll() is not None:
break
port = _ecat_find_port()
if port:
g_ecat_port = port
return port, []
time.sleep(0.3)
if g_ecat_child.poll() is None:
return None, logs + ["EtherCAT 服务仍在启动中(未监听端口),请稍候再试"]
g_ecat_child = None
if g_ecat_child is not None and g_ecat_child.poll() is not None:
g_ecat_child = None
if not os.path.isfile(ECAT_NATIVE_EXE):
return None, [
f"找不到 EtherCAT 原生守护进程: {ECAT_NATIVE_EXE}",
"请先运行 tools\\ecat\\build_native.bat 编译(需 VS2022 + Npcap)。",
]
# 无健康监听才清残留:禁止双实例抢网卡
killed = _kill_all_ecat_native()
if killed:
logs.append(f"已清掉 {killed} 个残留 ecat_native(单实例,防抢网卡)")
try:
# 日志落文件而不是 DEVNULL:native 的 stdout 是崩溃/楔死/自动重拉的
# 唯一现场(WEDGE、kill_stale、进 OP 全打这里),DEVNULL 吞掉后只能靠
# Windows 事件日志反推(2026-09-12 教训)。追加写,重启服务不清零。
if g_ecat_log_fp is None:
g_ecat_log_fp = open(
os.path.join(ECAT_DIR, "build_native", "native_stdout.log"),
"ab", buffering=0)
g_ecat_child = subprocess.Popen(
[ECAT_NATIVE_EXE, "--substr", ECAT_DEFAULT_ADAPTER,
"--mask", "0x50",
"--web", "src/web", "--parent-pid", str(os.getpid())],
cwd=ECAT_DIR,
stdout=g_ecat_log_fp, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
logs.append(f"已自动启动 EtherCAT 原生守护进程(pid {g_ecat_child.pid})")
if _set_ecat_realtime(g_ecat_child.pid):
logs.append(f"EtherCAT 进程优先级已提到 RealTime(pid {g_ecat_child.pid})")
else:
logs.append("EtherCAT 进程 RealTime 提权失败(保持默认/High, 可手动设)")
except OSError as e:
return None, logs + [f"启动 EtherCAT 服务失败: {type(e).__name__}: {e}"]
deadline = time.time() + 12
while time.time() < deadline:
if g_ecat_child.poll() is not None:
return None, logs + [
f"EtherCAT 守护进程启动后立刻退出(exit={g_ecat_child.returncode}) —— "
"多为 Npcap 未安装或 8090..8095 全被占; "
"可双击 tools/ecat/ecat_web.bat 看具体报错"]
port = _ecat_find_port()
if port:
g_ecat_port = port
logs.append(f"EtherCAT 服务已就绪(端口 {port})")
return port, logs
time.sleep(0.3)
if g_ecat_child.poll() is None:
return None, logs + ["EtherCAT 服务仍在启动中(未监听端口),请稍候再试"]
g_ecat_child = None
return None, logs + ["EtherCAT 服务启动超时(12s 内未监听端口)"]
if g_ecat_child is not None and g_ecat_child.poll() is not None:
g_ecat_child = None
if not os.path.isfile(ECAT_NATIVE_EXE):
return None, [
f"找不到 EtherCAT 原生守护进程: {ECAT_NATIVE_EXE}",
"请先运行 tools\\ecat\\build_native.bat 编译(需 VS2022 + Npcap)。",
]
# ── R1: 代理重发策略 ────────────────────────────────────────────────────
# 连接失败后 autostart 再打一次同一 POST,对写路径 = 可能二次执行。
# 仅对明确只读/可安全重试的路由允许 autostart 后自动重发一次。
_ECAT_IDEMPOTENT_TAILS = frozenset({
"status", "native/health", "app/load", "motor/state", "motor/rx",
"motor/version", "motor/dbg", "load",
})
# 起新进程前清光旧的:禁止双实例抢网卡
killed = _kill_all_ecat_native()
if killed:
logs.append(f"已清掉 {killed} 个残留 ecat_native(单实例,防抢网卡)")
try:
# 日志落文件而不是 DEVNULL:native 的 stdout 是崩溃/楔死/自动重拉的
# 唯一现场(WEDGE、kill_stale、进 OP 全打这里),DEVNULL 吞掉后只能靠
# Windows 事件日志反推(2026-09-12 教训)。追加写,重启服务不清零。
if g_ecat_log_fp is None:
g_ecat_log_fp = open(
os.path.join(ECAT_DIR, "build_native", "native_stdout.log"),
"ab", buffering=0)
g_ecat_child = subprocess.Popen(
[ECAT_NATIVE_EXE, "--substr", "I210", "--mask", "0x50",
"--web", "src/web", "--parent-pid", str(os.getpid())],
cwd=ECAT_DIR,
stdout=g_ecat_log_fp, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
logs.append(f"已自动启动 EtherCAT 原生守护进程(pid {g_ecat_child.pid})")
if _set_ecat_realtime(g_ecat_child.pid):
logs.append(f"EtherCAT 进程优先级已提到 RealTime(pid {g_ecat_child.pid})")
else:
logs.append("EtherCAT 进程 RealTime 提权失败(保持默认/High, 可手动设)")
except OSError as e:
return None, logs + [f"启动 EtherCAT 服务失败: {type(e).__name__}: {e}"]
def _ecat_target_idempotent(target: str) -> bool:
"""/api/<tail> 是否允许 autostart 后自动重发。默认 False(偏安全)。"""
tail = target[len("/api/"):] if target.startswith("/api/") else target.lstrip("/")
tail = tail.split("?", 1)[0].strip("/")
if tail in _ECAT_IDEMPOTENT_TAILS:
return True
# 显式只读前缀
if tail.startswith(("native/", "diag/")) and "stop" not in tail and "kill" not in tail:
return True
return False
deadline = time.time() + 12
while time.time() < deadline:
if g_ecat_child.poll() is not None:
return None, logs + [
f"EtherCAT 守护进程启动后立刻退出(exit={g_ecat_child.returncode}) —— "
"多为 Npcap 未安装或 8090..8095 全被占; "
"可双击 tools/ecat/ecat_web.bat 看具体报错"]
port = _ecat_find_port()
if port:
g_ecat_port = port
logs.append(f"EtherCAT 服务已就绪(端口 {port})")
return port, logs
time.sleep(0.3)
return None, logs + ["EtherCAT 服务启动超时(12s 内未监听端口)"]
def _ecat_connect_refused(exc) -> bool:
"""异常是否属于「根本没连上」(请求未发出,重发安全)。"""
if isinstance(exc, ConnectionRefusedError):
return True
if isinstance(exc, OSError):
if getattr(exc, "winerror", None) in (10061, 10065):
return True
if getattr(exc, "errno", None) in (111, 10061):
return True
msg = str(exc).lower()
return ("refused" in msg) or ("actively refused" in msg)
def _ecat_post(port, path, body, timeout, connect_timeout=None, ctype=None):
@@ -736,6 +835,7 @@ def _ecat_proxy(path, body, timeout=6.0, ctype=None):
global g_ecat_port
# /ecat/api/motor/state → /api/motor/state (只剥掉 "/ecat" 前缀, 保留 /api)
target = "/api/" + path[len(ECAT_PROXY_PREFIX):]
idem = _ecat_target_idempotent(target)
# 端口尝试顺序: 先缓存口, 失败再试其余漂移口(带短连接超时), 全灭才拉起。
# **健康路径上绝不能对死端口做预探测** —— 本机 Windows 对回环死端口的
# connect 不是秒拒(阻塞 ~2s, settimeout(0.3) 吃满 0.3s 回 WOULDBLOCK),
@@ -754,12 +854,17 @@ def _ecat_proxy(path, body, timeout=6.0, ctype=None):
ctype=ctype)
g_ecat_port = port
return status, data
except Exception as e: # 连不上/超时 → 试下一个端口
except Exception as e: # 连不上/超时 → 视情况试下一个端口
last = e
if g_ecat_port == port:
g_ecat_port = None # 缓存端口失效, 下轮重新全量探测
# R1: 非幂等请求一旦可能已到达旧实例(超时/非连接拒绝),
# 禁止再打其它端口——否则 flash/motor/send 等会被重复执行。
if not idem and not _ecat_connect_refused(e):
break
# 无存活端口或全失败 → 单实例拉起后重试一次
# 无存活端口或全失败 → 单实例拉起;**非幂等路径禁止自动重发**
start_logs = []
try:
port, start_logs = _ecat_autostart()
except Exception as e:
@@ -767,9 +872,21 @@ def _ecat_proxy(path, body, timeout=6.0, ctype=None):
port = None
start_logs = [f"autostart 异常: {type(e).__name__}: {e}"]
if port:
g_ecat_port = port
if not idem:
# 源请求可能已在旧实例上执行,或在超时途中被接收。
# 自动重发会二次写 flash / 重复 motor/start。返回 503,
# 由前端/用户显式重试(后端已就绪,重试通常一次即成功)。
msg = (
f"EtherCAT 服务已自动拉起,但 {target} 为非幂等路径,"
"已按约定不自动重发,请重试一次"
)
body = json.dumps(
{"ok": False, "log": start_logs + [msg]},
ensure_ascii=False).encode("utf-8")
return 503, body
try:
status, data = _ecat_post(port, target, body, timeout, ctype=ctype)
g_ecat_port = port
return status, data
except Exception as e:
last = e