diff --git a/README.md b/README.md index 55707f3..ab3e8bb 100644 --- a/README.md +++ b/README.md @@ -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 循环。 diff --git a/esp32_server.py b/esp32_server.py index 74d1b4e..af9817a 100644 --- a/esp32_server.py +++ b/esp32_server.py @@ -131,7 +131,7 @@ user-select:none;opacity:.55;transition:opacity .15s} _RELOAD_SNIPPET = _RELOAD_SNIPPET.replace("__HOME__", "/tabs.html") _RELOAD_MARK = "" -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/ 是否允许 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