fix(server): P0 代理非幂等不重发 + 跨进程 native 互斥复用 + 默认网卡 I210
R1: autostart 后禁止自动重发 flash/motor/send 等副作用路径;
超时类失败也不再串打其它端口。
R2: README 默认网卡改为与代码一致的 I210。
R3: 命名互斥锁 + 先复用健康监听实例,避免 GUI/服务子进程互相 kill。
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user