Files

315 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""ecat_proc.py — ecat_native 进程治理(跨进程锁 / autostart / kill / RealTime)。
从 esp32_server.py 外提(R8)。职责是「谁有权把 ecat_native 拉起来/杀掉」,
不碰 HTTP/静态/lease——那些留在 esp32_server.py。
路径注入:BASE_DIR **只在 esp32_server 定义一次**,启动时调用
`init(BASE_DIR)` 设置本模块的 ECAT_DIR / ECAT_NATIVE_EXE,并向
ecat_client 注册 autostart 钩子。
保留 P0 行为(不得回退):
- 跨进程命名互斥锁 _EcatProcXLock(GUI 进程 + 服务子进程串行 kill/start)
- **先复用健康监听实例**,绝不 kill 对方刚拉起的 native
- 默认网卡 I210(与 js/common/device-link.js / pages/motor.html 一致,R2)
- 拉起时带 --parent-pid,native 在本服务退出后自我了断
- stdout 落盘(DEVNULL 会吞掉崩溃现场)
"""
from __future__ import annotations
import ctypes
import os
import subprocess
import threading
import time
import ecat_client
# 网卡默认子串:与 js/common/device-link.js / pages/motor.html 保持一致(R2)
ECAT_DEFAULT_ADAPTER = "I210"
ECAT_DIR = None
ECAT_NATIVE_EXE = None
g_ecat_child = None # 由本服务拉起的 ecat 服务子进程(复用已有实例时为 None)
g_ecat_log_fp = None # native stdout 落盘句柄(见 autostart 内注释)
g_ecat_lock = threading.Lock() # 串行化自动启动, 避免并发请求拉起多个进程
# R3: 跨进程互斥(GUI 进程 + 服务子进程都可能 autostart/kill native)
_ECAT_XPROC_MUTEX_NAME = "Local\\DCU_Esp32Tools_EcatNativeProc"
def _resolve_ecat_dir(base_dir):
"""定位 tools/ecat(ecat_native.exe 的仓库,与本仓库平级)。
从 git worktree(.../tools/ESP32/.worktrees/<slug>)启动时,BASE_DIR 的
上一级是 .worktrees 而不是 tools,直接 join("..","ecat") 会拼成
.../ESP32/.worktrees/ecat,找不到 exe。先走回主检出再取兄弟目录。
"""
d = os.path.abspath(base_dir)
parent = os.path.dirname(d)
if os.path.basename(parent) == ".worktrees":
d = os.path.dirname(parent) # → .../tools/ESP32 主检出
return os.path.normpath(os.path.join(d, "..", "ecat"))
def init(base_dir: str) -> dict:
"""由 esp32_server 在导入时调用:注入 BASE_DIR,注册 autostart 钩子。"""
global ECAT_DIR, ECAT_NATIVE_EXE
ECAT_DIR = _resolve_ecat_dir(base_dir)
ECAT_NATIVE_EXE = os.path.join(ECAT_DIR, "build_native", "ecat_native.exe")
ecat_client.set_autostart_hook(autostart)
return {"ecat_dir": ECAT_DIR, "native_exe": ECAT_NATIVE_EXE}
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 _set_ecat_realtime(pid: int) -> bool:
"""把 ecat_native 提到 RealTime 进程优先级。
native 自己 SetPriorityClass(RT) 会被系统静默钳成 High(令牌无
SeIncreaseBasePriorityPrivilege)。由本服务(启动方)从外部设置才能粘住。
pdo 线程已是 TIME_CRITICAL:进程类 High→线程 15,RealTime→线程 31,
后者才扛得住桌面 DPC/调度抢占。实测 1ms 档 late ~12/s → 个位数。
失败返回 False,不影响启动。
"""
try:
import ctypes.wintypes as wt # noqa: F401
PROCESS_SET_INFORMATION = 0x0200
REALTIME_PRIORITY_CLASS = 0x00000100
HIGH_PRIORITY_CLASS = 0x00000080
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
h = k32.OpenProcess(PROCESS_SET_INFORMATION, False, int(pid))
if not h:
return False
try:
if k32.SetPriorityClass(h, REALTIME_PRIORITY_CLASS):
return True
return bool(k32.SetPriorityClass(h, HIGH_PRIORITY_CLASS))
finally:
k32.CloseHandle(h)
except Exception:
return False
def _ecat_reuse_listening_native():
"""端口上已有健康 native → 直接复用(可能是另一进程刚拉起的),不 kill。"""
g_port = ecat_client.g_ecat_port
ports = ([g_port] if g_port else []) + \
[p for p in ecat_client.ECAT_PORTS if p != g_port]
for p in ports:
if not ecat_client._port_taken(p):
continue
try:
status, data = ecat_client._ecat_post(
p, "/api/native/health", b"{}", 1.0,
connect_timeout=0.4)
if status == 200:
return p
except Exception:
continue
return None
def _kill_all_ecat_native():
"""强杀所有 ecat_native.exe,返回杀掉的个数。
两个 native 并存会抢 Npcap 适配器:后起的抓不到从站、HTTP 超时、
PDO 空转。启动新实例前必须保证旧的死透。只按进程名杀,不碰 python。
"""
try:
out = subprocess.check_output(
["tasklist", "/FI", "IMAGENAME eq ecat_native.exe",
"/FO", "CSV", "/NH"],
text=True, stderr=subprocess.DEVNULL, timeout=8,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
) or ""
except Exception:
return 0
pids = []
for line in (out or "").splitlines():
parts = [p.strip().strip('"') for p in line.split(",")]
if len(parts) >= 2 and parts[0].lower() == "ecat_native.exe":
try:
pids.append(int(parts[1]))
except ValueError:
pass
if not pids:
return 0
for pid in pids:
try:
subprocess.run(
["taskkill", "/F", "/PID", str(pid)],
capture_output=True, timeout=5,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except Exception:
pass
# 等 Npcap 句柄随进程退出由内核回收
deadline = time.time() + 3.0
while time.time() < deadline:
try:
again = subprocess.check_output(
["tasklist", "/FI", "IMAGENAME eq ecat_native.exe",
"/FO", "CSV", "/NH"],
text=True, stderr=subprocess.DEVNULL, timeout=4,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
) or ""
if "ecat_native.exe" not in (again or "").lower():
break
except Exception:
break
time.sleep(0.2)
else:
time.sleep(0.3)
return len(pids)
def autostart():
"""按需拉起 tools/ecat 的服务进程。返回 (port, log_lines)。
单实例策略(2026-09-12,R3 加固 2026-09-23):
- 跨进程命名互斥锁串行化 kill/start(GUI 进程与服务子进程共用)。
- **先探测健康监听实例并复用**,绝不 kill 对方刚拉起的 native。
- 本进程子进程仍存活 → 只等它听口,**绝不 kill_all**(否则启动中的实例
会被自己下一次请求杀掉,形成 8s kill_stale 死循环)。
- 子进程已死 / 从未拉起且无健康监听 → 先 taskkill 全部残留 ecat_native,再起唯一实例。
port 为 None 表示拉不起来。
"""
global g_ecat_child, g_ecat_log_fp
if not ECAT_NATIVE_EXE:
return None, ["ecat_proc.init 未调用(BASE_DIR 未注入)"]
with _EcatProcXLock():
with g_ecat_lock:
logs = []
# ── R3: 另一进程(GUI/服务)已在跑的健康实例 → 复用 ──
reuse = _ecat_reuse_listening_native()
if reuse:
ecat_client.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_client._ecat_find_port()
if port:
ecat_client.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_client._ecat_find_port()
if port:
ecat_client.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_client._ecat_find_port()
if port:
ecat_client.g_ecat_port = port
logs.append(f"EtherCAT 服务已就绪(端口 {port})")
return port, logs
time.sleep(0.3)
return None, logs + ["EtherCAT 服务启动超时(12s 内未监听端口)"]
# 旧名兼容(server / 脚本)
_ecat_autostart = autostart
def watch_parent(ppid):
"""兜底看门狗(Job Object 万一不可用时): 父 GUI 进程消失就自我了断,
避免窗口关了服务还在后台占端口。"""
def _loop():
k32 = ctypes.windll.kernel32
h = k32.OpenProcess(0x00100000, False, ppid) # SYNCHRONIZE
if not h:
return
k32.WaitForSingleObject(h, 0xFFFFFFFF) # 父进程退出即返回
os._exit(0)
threading.Thread(target=_loop, daemon=True).start()
_watch_parent = watch_parent